gamealgo-cli 0.3.6 → 0.3.8
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/client.js +37 -5
- package/dist/commands/dynamic-config.js +71 -0
- package/dist/commands/events.js +36 -2
- package/dist/commands/experiment.js +7 -3
- package/dist/commands/report.js +161 -29
- package/dist/event-catalog-diff.js +113 -0
- package/dist/http.js +30 -1
- package/dist/main.js +7 -1
- package/dist/output.js +10 -0
- package/dist/report-csv.js +87 -0
- package/dist/report-summary.js +300 -0
- package/dist/utils.js +6 -0
- package/package.json +1 -1
- package/skills/gamealgo/SKILL.md +91 -2
package/dist/client.js
CHANGED
|
@@ -58,10 +58,11 @@ export class GameAlgoAdminClient {
|
|
|
58
58
|
async archiveExperimentV2Strategy(strategyKey, archived) {
|
|
59
59
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/archive`, { archived });
|
|
60
60
|
}
|
|
61
|
-
async rollbackExperimentV2Strategy(strategyKey, versionId, message) {
|
|
61
|
+
async rollbackExperimentV2Strategy(strategyKey, versionId, message, platform) {
|
|
62
62
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/strategies-v2/${encodeURIComponent(strategyKey)}/rollback`, {
|
|
63
63
|
versionId,
|
|
64
64
|
message,
|
|
65
|
+
...(platform ? { platform } : {}),
|
|
65
66
|
});
|
|
66
67
|
}
|
|
67
68
|
async createExperimentV2Run(strategyKey, body) {
|
|
@@ -133,17 +134,39 @@ export class GameAlgoAdminClient {
|
|
|
133
134
|
async getScriptVersion(name, versionId) {
|
|
134
135
|
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/scripts/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`);
|
|
135
136
|
}
|
|
137
|
+
async listDynamicConfigs() {
|
|
138
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/dynamic-configs`);
|
|
139
|
+
}
|
|
140
|
+
async getDynamicConfig(key) {
|
|
141
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/dynamic-configs/${encodeURIComponent(key)}`);
|
|
142
|
+
}
|
|
143
|
+
async putDynamicConfig(key, body) {
|
|
144
|
+
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/dynamic-configs/${encodeURIComponent(key)}`, body);
|
|
145
|
+
}
|
|
146
|
+
async queueDynamicConfigRun(key) {
|
|
147
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/dynamic-configs/${encodeURIComponent(key)}/run`, {});
|
|
148
|
+
}
|
|
149
|
+
async listDynamicConfigRuns(key, limit = 50) {
|
|
150
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/dynamic-configs/${encodeURIComponent(key)}/runs?limit=${encodeURIComponent(String(limit))}`);
|
|
151
|
+
}
|
|
136
152
|
async getReportPack() {
|
|
137
153
|
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`);
|
|
138
154
|
}
|
|
139
|
-
async previewReportPack(content) {
|
|
140
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack/preview`, {
|
|
155
|
+
async previewReportPack(content, options = {}) {
|
|
156
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack/preview`, {
|
|
157
|
+
content,
|
|
158
|
+
...(options.responseMode ? { responseMode: options.responseMode } : {}),
|
|
159
|
+
});
|
|
141
160
|
}
|
|
142
161
|
async putReportPack(content) {
|
|
143
162
|
return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`, { content });
|
|
144
163
|
}
|
|
145
|
-
async reportManifest() {
|
|
146
|
-
|
|
164
|
+
async reportManifest(options = {}) {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
if (options.responseMode)
|
|
167
|
+
params.set("responseMode", options.responseMode);
|
|
168
|
+
const query = params.size ? `?${params.toString()}` : "";
|
|
169
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/manifest${query}`);
|
|
147
170
|
}
|
|
148
171
|
async queryReportDashboard(body, options = {}) {
|
|
149
172
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/query`, body, options);
|
|
@@ -173,6 +196,15 @@ export class GameAlgoAdminClient {
|
|
|
173
196
|
async deprecateEventCatalog(eventType) {
|
|
174
197
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/catalog/${encodeURIComponent(eventType)}/deprecate`, {});
|
|
175
198
|
}
|
|
199
|
+
async blockEventCatalog(eventType, reason) {
|
|
200
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/catalog/${encodeURIComponent(eventType)}/block`, { reason });
|
|
201
|
+
}
|
|
202
|
+
async activateEventCatalog(eventType, reason, confirmEventType) {
|
|
203
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/catalog/${encodeURIComponent(eventType)}/activate`, {
|
|
204
|
+
reason,
|
|
205
|
+
confirmEventType,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
176
208
|
async getIntegrationPlan(environment, platform) {
|
|
177
209
|
const params = new URLSearchParams({ environment, platform });
|
|
178
210
|
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/integration/plan?${params.toString()}`);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { parseFlags } from "../args.js";
|
|
4
|
+
import { printResult } from "../output.js";
|
|
5
|
+
import { contentTypeForFileName, objectValue, readJsonFile, sanitizeRemoteFileName } from "../resources.js";
|
|
6
|
+
import { announceTargetGame } from "../target.js";
|
|
7
|
+
import { optionalString } from "../utils.js";
|
|
8
|
+
export async function handleDynamicConfig(client, args, global) {
|
|
9
|
+
const sub = args.shift();
|
|
10
|
+
if (sub === "list") {
|
|
11
|
+
parseFlags(args);
|
|
12
|
+
await printResult(await client.listDynamicConfigs(), global);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (sub === "get") {
|
|
16
|
+
const key = args.shift();
|
|
17
|
+
if (!key)
|
|
18
|
+
throw new Error("usage: gamealgo dynamic-config get <key>");
|
|
19
|
+
parseFlags(args);
|
|
20
|
+
await printResult(await client.getDynamicConfig(key), global);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (sub === "runs") {
|
|
24
|
+
const key = args.shift();
|
|
25
|
+
if (!key)
|
|
26
|
+
throw new Error("usage: gamealgo dynamic-config runs <key> [--limit 50]");
|
|
27
|
+
const flags = parseFlags(args);
|
|
28
|
+
await printResult(await client.listDynamicConfigRuns(key, Number(flags.limit || 50)), global);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (sub === "run") {
|
|
32
|
+
const key = args.shift();
|
|
33
|
+
if (!key)
|
|
34
|
+
throw new Error("usage: gamealgo dynamic-config run <key>");
|
|
35
|
+
parseFlags(args);
|
|
36
|
+
await announceTargetGame(client);
|
|
37
|
+
await printResult(await client.queueDynamicConfigRun(key), global);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub === "publish") {
|
|
41
|
+
const definitionPath = args.shift();
|
|
42
|
+
if (!definitionPath) {
|
|
43
|
+
throw new Error("usage: gamealgo dynamic-config publish <definition.json> [--script transform.js] [--message text]");
|
|
44
|
+
}
|
|
45
|
+
const flags = parseFlags(args);
|
|
46
|
+
const definition = objectValue(await readJsonFile(definitionPath));
|
|
47
|
+
if (!definition)
|
|
48
|
+
throw new Error("dynamic config definition must be a JSON object");
|
|
49
|
+
const key = optionalString(definition.key);
|
|
50
|
+
if (!key)
|
|
51
|
+
throw new Error("dynamic config definition.key is required");
|
|
52
|
+
const body = { ...definition };
|
|
53
|
+
delete body.key;
|
|
54
|
+
const scriptPath = optionalString(flags.script);
|
|
55
|
+
await announceTargetGame(client);
|
|
56
|
+
if (scriptPath) {
|
|
57
|
+
const scriptName = sanitizeRemoteFileName(optionalString(definition.scriptName) || basename(scriptPath));
|
|
58
|
+
const content = await readFile(scriptPath, "utf8");
|
|
59
|
+
const created = await client.createScriptVersion(scriptName, content, contentTypeForFileName(scriptName), optionalString(flags.message));
|
|
60
|
+
body.scriptVersionId = created.scriptVersion.versionId;
|
|
61
|
+
}
|
|
62
|
+
delete body.scriptName;
|
|
63
|
+
if (!optionalString(body.scriptVersionId)) {
|
|
64
|
+
throw new Error("definition.scriptVersionId or --script is required");
|
|
65
|
+
}
|
|
66
|
+
const response = await client.putDynamicConfig(key, body);
|
|
67
|
+
await printResult({ ok: true, ...response }, global);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new Error("usage: gamealgo dynamic-config <list|get|publish|run|runs>");
|
|
71
|
+
}
|
package/dist/commands/events.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import YAML from "yaml";
|
|
2
2
|
import { parseFlags } from "../args.js";
|
|
3
|
+
import { diffReportPackEventCatalog } from "../event-catalog-diff.js";
|
|
3
4
|
import { confirm, formatDuration, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
|
|
4
|
-
import { readObjectFile, writeTextFile } from "../resources.js";
|
|
5
|
+
import { readJsonFile, readObjectFile, writeTextFile } from "../resources.js";
|
|
5
6
|
import { announceTargetGame } from "../target.js";
|
|
6
7
|
import { optionalString } from "../utils.js";
|
|
7
8
|
export async function handleEvents(client, args, global) {
|
|
8
9
|
const sub = args.shift();
|
|
9
10
|
const flags = parseFlags(args);
|
|
11
|
+
if (sub === "diff") {
|
|
12
|
+
const filePath = optionalString(flags.pack) || args.shift();
|
|
13
|
+
if (!filePath || args.length)
|
|
14
|
+
throw new Error("usage: gamealgo events diff --pack <report-pack.json>");
|
|
15
|
+
const reportPack = await readJsonFile(filePath);
|
|
16
|
+
const catalog = await client.listEventCatalog(30, false);
|
|
17
|
+
await printResult(diffReportPackEventCatalog(reportPack, catalog), global);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
10
20
|
if (sub === "list") {
|
|
11
21
|
if (args.length)
|
|
12
22
|
throw new Error("usage: gamealgo events list [--days 30] [--no-diagnostics]");
|
|
@@ -41,8 +51,32 @@ export async function handleEvents(client, args, global) {
|
|
|
41
51
|
await writeEventCatalogOutput(await client.deprecateEventCatalog(eventType), flags, global);
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
54
|
+
if (sub === "block") {
|
|
55
|
+
const eventType = args.shift();
|
|
56
|
+
const reason = optionalString(flags.reason);
|
|
57
|
+
if (!eventType || args.length || !reason)
|
|
58
|
+
throw new Error("usage: gamealgo events block <eventType> --reason <reason> [--yes]");
|
|
59
|
+
if (!flags.yes && !(await confirm(`Block event ${eventType}? New occurrences will be dropped before Kafka.`)))
|
|
60
|
+
throw new Error("Cancelled");
|
|
61
|
+
await announceTargetGame(client);
|
|
62
|
+
await writeEventCatalogOutput(await client.blockEventCatalog(eventType, reason), flags, global);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (sub === "activate") {
|
|
66
|
+
const eventType = args.shift();
|
|
67
|
+
const reason = optionalString(flags.reason);
|
|
68
|
+
const confirmation = optionalString(flags.confirm);
|
|
69
|
+
if (!eventType || args.length || !reason || !confirmation) {
|
|
70
|
+
throw new Error("usage: gamealgo events activate <eventType> --reason <reason> --confirm <eventType>");
|
|
71
|
+
}
|
|
72
|
+
if (confirmation !== eventType)
|
|
73
|
+
throw new Error("--confirm must exactly match eventType");
|
|
74
|
+
await announceTargetGame(client);
|
|
75
|
+
await writeEventCatalogOutput(await client.activateEventCatalog(eventType, reason, confirmation), flags, global);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
44
78
|
if (sub !== "count")
|
|
45
|
-
throw new Error("usage: gamealgo events <list|get|apply|deprecate|count>");
|
|
79
|
+
throw new Error("usage: gamealgo events <list|get|diff|apply|deprecate|block|activate|count>");
|
|
46
80
|
const timeoutMs = reportTimeoutMs(flags);
|
|
47
81
|
const startedAt = Date.now();
|
|
48
82
|
const stopProgress = startProgress("Querying event counts", timeoutMs);
|
|
@@ -2,7 +2,7 @@ import YAML from "yaml";
|
|
|
2
2
|
import { parseFlags } from "../args.js";
|
|
3
3
|
import { confirm, isInteractive, optionalNonNegativeInteger, positiveInteger, printResult } from "../output.js";
|
|
4
4
|
import { objectValue, printExperimentRunVariantScripts, readObjectFile, validateExperimentRunVariantScripts, writeTextFile } from "../resources.js";
|
|
5
|
-
import { optionalString } from "../utils.js";
|
|
5
|
+
import { experimentPlatform, optionalString } from "../utils.js";
|
|
6
6
|
export async function handleExperiment(client, args, global) {
|
|
7
7
|
const sub = args.shift();
|
|
8
8
|
if (sub === "strategies" || sub === "list") {
|
|
@@ -126,12 +126,13 @@ export async function handleExperimentStrategy(client, args, global) {
|
|
|
126
126
|
const flags = parseFlags(args);
|
|
127
127
|
const strategyKey = optionalString(flags.strategy || flags.key) || optionalString(args.shift());
|
|
128
128
|
const versionId = optionalString(flags.version || flags["version-id"]);
|
|
129
|
+
const platform = flags.platform === undefined ? undefined : experimentPlatform(flags.platform);
|
|
129
130
|
const message = requiredMessage(flags, "experiment strategy rollback");
|
|
130
131
|
if (!strategyKey || !versionId) {
|
|
131
|
-
throw new Error("usage: gamealgo experiment strategy rollback <strategyKey> --version-id <versionId> --message <reason> --yes");
|
|
132
|
+
throw new Error("usage: gamealgo experiment strategy rollback <strategyKey> --version-id <versionId> [--platform ios|android|rest] --message <reason> --yes");
|
|
132
133
|
}
|
|
133
134
|
await requireYes(flags, global, "experiment strategy rollback");
|
|
134
|
-
await printResult(await client.rollbackExperimentV2Strategy(strategyKey, versionId, message), global);
|
|
135
|
+
await printResult(await client.rollbackExperimentV2Strategy(strategyKey, versionId, message, platform), global);
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
throw new Error("usage: gamealgo experiment strategy <show|publish|default|archive|restore|rollback>");
|
|
@@ -219,11 +220,14 @@ export async function handleExperimentRun(client, args, global) {
|
|
|
219
220
|
const run = response.run;
|
|
220
221
|
if (!run)
|
|
221
222
|
throw new Error(`experiment run not found: ${runId}`);
|
|
223
|
+
const reportConfig = objectValue(run.reportConfig);
|
|
224
|
+
const managedConfig = objectValue(reportConfig?.managed);
|
|
222
225
|
await printResult({
|
|
223
226
|
run: {
|
|
224
227
|
id: run.id,
|
|
225
228
|
strategyKey: run.strategyKey,
|
|
226
229
|
displayName: run.displayName,
|
|
230
|
+
platform: optionalString(reportConfig?.platform) ?? optionalString(managedConfig?.platform) ?? null,
|
|
227
231
|
type: run.type,
|
|
228
232
|
status: run.status,
|
|
229
233
|
objective: run.objective,
|
package/dist/commands/report.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { extname, join } from "node:path";
|
|
1
2
|
import { parseFlags, parseSelectorFlags } from "../args.js";
|
|
2
|
-
import { formatDuration, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
|
|
3
|
+
import { formatDuration, markCommandFailed, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
|
|
4
|
+
import { reportDashboardCsv, reportDashboardCsvExports } from "../report-csv.js";
|
|
5
|
+
import { normalizeReportSampleRows, summarizeReportDashboard, summarizeReportManifest, summarizeReportValidation, } from "../report-summary.js";
|
|
3
6
|
import { objectValue, readJsonFile, writeTextFile } from "../resources.js";
|
|
4
7
|
import { announceTargetGame } from "../target.js";
|
|
5
8
|
import { optionalString } from "../utils.js";
|
|
@@ -16,9 +19,15 @@ export async function handleReport(client, args, global) {
|
|
|
16
19
|
if (sub === "validate") {
|
|
17
20
|
const filePath = args.shift();
|
|
18
21
|
if (!filePath)
|
|
19
|
-
throw new Error("usage: gamealgo report validate <report-pack.json>");
|
|
22
|
+
throw new Error("usage: gamealgo report validate <report-pack.json> [--full]");
|
|
23
|
+
const flags = parseFlags(args);
|
|
24
|
+
const full = reportFullOutput(flags);
|
|
20
25
|
const content = await readJsonFile(filePath);
|
|
21
|
-
|
|
26
|
+
const response = await client.previewReportPack(content, { responseMode: full ? "full" : "summary" });
|
|
27
|
+
const summary = summarizeReportValidation(response);
|
|
28
|
+
await printResult(full ? response : summary, global);
|
|
29
|
+
if (!summary.ok)
|
|
30
|
+
markCommandFailed();
|
|
22
31
|
return;
|
|
23
32
|
}
|
|
24
33
|
if (sub === "publish") {
|
|
@@ -33,8 +42,10 @@ export async function handleReport(client, args, global) {
|
|
|
33
42
|
return;
|
|
34
43
|
}
|
|
35
44
|
if (sub === "manifest" || sub === "list") {
|
|
36
|
-
parseFlags(args);
|
|
37
|
-
|
|
45
|
+
const flags = parseFlags(args);
|
|
46
|
+
const full = reportFullOutput(flags);
|
|
47
|
+
const response = await client.reportManifest({ responseMode: full ? "full" : "summary" });
|
|
48
|
+
await printResult(full ? response : summarizeReportManifest(response), global);
|
|
38
49
|
return;
|
|
39
50
|
}
|
|
40
51
|
if (sub === "result") {
|
|
@@ -43,8 +54,12 @@ export async function handleReport(client, args, global) {
|
|
|
43
54
|
const startDate = String(flags.from || flags.start || flags.startDate || "");
|
|
44
55
|
const endDate = String(flags.to || flags.end || flags.endDate || "");
|
|
45
56
|
if (!startDate || !endDate)
|
|
46
|
-
throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v]");
|
|
57
|
+
throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v] [--sample-rows 0..20] [--full] [--format json|csv] [--out file-or-directory]");
|
|
58
|
+
requireReportQueryScope(flags, "report result");
|
|
47
59
|
const timeoutMs = reportTimeoutMs(flags);
|
|
60
|
+
const format = reportOutputFormat(flags.format);
|
|
61
|
+
const full = reportFullOutput(flags) || format === "csv";
|
|
62
|
+
const sampleRows = reportSampleRows(flags, full);
|
|
48
63
|
const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
|
|
49
64
|
const startedAt = Date.now();
|
|
50
65
|
const stopProgress = startProgress("Querying report results", timeoutMs);
|
|
@@ -62,25 +77,19 @@ export async function handleReport(client, args, global) {
|
|
|
62
77
|
platform,
|
|
63
78
|
selectors,
|
|
64
79
|
refresh: Boolean(flags.refresh),
|
|
80
|
+
responseMode: full ? "full" : "summary",
|
|
81
|
+
sampleRows,
|
|
65
82
|
}, { timeoutMs });
|
|
66
83
|
}
|
|
67
84
|
finally {
|
|
68
85
|
stopProgress();
|
|
69
86
|
}
|
|
70
87
|
const elapsedMs = Date.now() - startedAt;
|
|
71
|
-
const
|
|
88
|
+
const rawOutputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
89
|
+
const statusSummary = summarizeReportDashboard(rawOutputValue, { command: "report.result", sampleRows });
|
|
90
|
+
const outputValue = full ? rawOutputValue : statusSummary;
|
|
72
91
|
process.stderr.write(`Report query finished in ${formatDuration(elapsedMs)}.\n`);
|
|
73
|
-
|
|
74
|
-
await writeTextFile(String(flags.out), JSON.stringify(outputValue, null, 2) + "\n");
|
|
75
|
-
await printResult({
|
|
76
|
-
ok: true,
|
|
77
|
-
out: String(flags.out),
|
|
78
|
-
results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
|
|
79
|
-
elapsedMs,
|
|
80
|
-
}, global);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
await printResult(outputValue, global);
|
|
92
|
+
await writeReportQueryOutput(outputValue, flags, global, elapsedMs, statusSummary.ok);
|
|
84
93
|
return;
|
|
85
94
|
}
|
|
86
95
|
if (sub === "preview") {
|
|
@@ -90,10 +99,14 @@ export async function handleReport(client, args, global) {
|
|
|
90
99
|
const startDate = String(flags.from || flags.start || flags.startDate || "");
|
|
91
100
|
const endDate = String(flags.to || flags.end || flags.endDate || "");
|
|
92
101
|
if (!filePath || !startDate || !endDate) {
|
|
93
|
-
throw new Error("usage: gamealgo report preview --pack report-pack.json --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v]");
|
|
102
|
+
throw new Error("usage: gamealgo report preview --pack report-pack.json --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v] [--sample-rows 0..20] [--full] [--format json|csv] [--out file-or-directory]");
|
|
94
103
|
}
|
|
104
|
+
requireReportQueryScope(flags, "report preview");
|
|
95
105
|
const content = await readJsonFile(filePath);
|
|
96
106
|
const timeoutMs = reportTimeoutMs(flags);
|
|
107
|
+
const format = reportOutputFormat(flags.format);
|
|
108
|
+
const full = reportFullOutput(flags) || format === "csv";
|
|
109
|
+
const sampleRows = reportSampleRows(flags, full);
|
|
97
110
|
const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
|
|
98
111
|
const startedAt = Date.now();
|
|
99
112
|
const stopProgress = startProgress("Previewing report results", timeoutMs);
|
|
@@ -111,28 +124,147 @@ export async function handleReport(client, args, global) {
|
|
|
111
124
|
chartId: optionalString(flags["chart-id"]),
|
|
112
125
|
platform,
|
|
113
126
|
selectors,
|
|
127
|
+
responseMode: full ? "full" : "summary",
|
|
128
|
+
sampleRows,
|
|
114
129
|
}, { timeoutMs });
|
|
115
130
|
}
|
|
116
131
|
finally {
|
|
117
132
|
stopProgress();
|
|
118
133
|
}
|
|
119
134
|
const elapsedMs = Date.now() - startedAt;
|
|
120
|
-
const
|
|
135
|
+
const rawOutputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
136
|
+
const statusSummary = summarizeReportDashboard(rawOutputValue, { command: "report.preview", sampleRows });
|
|
137
|
+
const outputValue = full ? rawOutputValue : statusSummary;
|
|
121
138
|
process.stderr.write(`Report preview finished in ${formatDuration(elapsedMs)}.\n`);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
await writeReportQueryOutput(outputValue, flags, global, elapsedMs, statusSummary.ok);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview>");
|
|
143
|
+
}
|
|
144
|
+
function reportFullOutput(flags) {
|
|
145
|
+
if (flags.full === undefined)
|
|
146
|
+
return false;
|
|
147
|
+
if (flags.full !== true)
|
|
148
|
+
throw new Error("--full does not accept a value");
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
function reportSampleRows(flags, full) {
|
|
152
|
+
const sampleRows = normalizeReportSampleRows(flags["sample-rows"]);
|
|
153
|
+
if (full && flags["sample-rows"] !== undefined) {
|
|
154
|
+
throw new Error("--sample-rows cannot be combined with --full or --format csv");
|
|
155
|
+
}
|
|
156
|
+
return sampleRows;
|
|
157
|
+
}
|
|
158
|
+
function requireReportQueryScope(flags, command) {
|
|
159
|
+
const scopeFlags = ["tab", "tab-id", "group", "group-id", "chart", "chart-id"];
|
|
160
|
+
if (scopeFlags.some((flag) => optionalString(flags[flag])))
|
|
161
|
+
return;
|
|
162
|
+
throw new Error(`${command} requires one of --tab, --tab-id, --group, --group-id, --chart, or --chart-id`);
|
|
163
|
+
}
|
|
164
|
+
export function reportOutputFormat(value) {
|
|
165
|
+
if (value === undefined || value === null || value === "")
|
|
166
|
+
return "json";
|
|
167
|
+
if (typeof value !== "string")
|
|
168
|
+
throw new Error("--format requires json or csv");
|
|
169
|
+
const normalized = value.trim().toLowerCase();
|
|
170
|
+
if (normalized === "json" || normalized === "csv")
|
|
171
|
+
return normalized;
|
|
172
|
+
throw new Error("--format must be json or csv");
|
|
173
|
+
}
|
|
174
|
+
async function writeReportQueryOutput(outputValue, flags, global, elapsedMs, succeeded) {
|
|
175
|
+
const format = reportOutputFormat(flags.format);
|
|
176
|
+
const out = optionalString(flags.out);
|
|
177
|
+
if (flags.out === true)
|
|
178
|
+
throw new Error("--out requires a file path");
|
|
179
|
+
if (format === "csv") {
|
|
180
|
+
if (!out && global.json)
|
|
181
|
+
throw new Error("--format csv cannot be combined with --json unless --out is provided");
|
|
182
|
+
if (out) {
|
|
183
|
+
const exports = reportDashboardCsvExports(outputValue);
|
|
184
|
+
const files = reportCsvOutputFiles(exports, out);
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
warnTruncatedCsv(file.exported);
|
|
187
|
+
await writeTextFile(file.out, file.exported.content);
|
|
188
|
+
}
|
|
189
|
+
const first = files[0];
|
|
190
|
+
const summary = files.length === 1 ? {
|
|
191
|
+
ok: succeeded,
|
|
192
|
+
out: first.out,
|
|
193
|
+
format,
|
|
194
|
+
reportId: first.exported.reportId,
|
|
195
|
+
chartId: first.exported.chartId,
|
|
196
|
+
chartTitle: first.exported.chartTitle,
|
|
197
|
+
rows: first.exported.rowCount,
|
|
198
|
+
columns: first.exported.columns.length,
|
|
199
|
+
truncated: first.exported.truncated,
|
|
128
200
|
elapsedMs,
|
|
129
|
-
}
|
|
201
|
+
} : {
|
|
202
|
+
ok: succeeded,
|
|
203
|
+
out,
|
|
204
|
+
format,
|
|
205
|
+
results: files.length,
|
|
206
|
+
files: files.map((file) => ({
|
|
207
|
+
out: file.out,
|
|
208
|
+
reportId: file.exported.reportId,
|
|
209
|
+
chartId: file.exported.chartId,
|
|
210
|
+
chartTitle: file.exported.chartTitle,
|
|
211
|
+
rows: file.exported.rowCount,
|
|
212
|
+
columns: file.exported.columns.length,
|
|
213
|
+
truncated: file.exported.truncated,
|
|
214
|
+
})),
|
|
215
|
+
elapsedMs,
|
|
216
|
+
};
|
|
217
|
+
await printResult(summary, global);
|
|
130
218
|
return;
|
|
131
219
|
}
|
|
132
|
-
|
|
220
|
+
const exported = reportDashboardCsv(outputValue);
|
|
221
|
+
warnTruncatedCsv(exported);
|
|
222
|
+
process.stdout.write(exported.content);
|
|
223
|
+
if (!succeeded)
|
|
224
|
+
markCommandFailed();
|
|
133
225
|
return;
|
|
134
226
|
}
|
|
135
|
-
|
|
227
|
+
if (out) {
|
|
228
|
+
await writeTextFile(out, JSON.stringify(outputValue, null, 2) + "\n");
|
|
229
|
+
const outputObject = outputValue && typeof outputValue === "object" && !Array.isArray(outputValue)
|
|
230
|
+
? outputValue
|
|
231
|
+
: {};
|
|
232
|
+
await printResult({
|
|
233
|
+
ok: succeeded,
|
|
234
|
+
out,
|
|
235
|
+
results: Array.isArray(outputObject.results)
|
|
236
|
+
? outputObject.results.length
|
|
237
|
+
: Array.isArray(outputObject.items) ? outputObject.items.length : 0,
|
|
238
|
+
elapsedMs,
|
|
239
|
+
}, global);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await printResult(outputValue, global);
|
|
243
|
+
if (!succeeded)
|
|
244
|
+
markCommandFailed();
|
|
245
|
+
}
|
|
246
|
+
function reportCsvOutputFiles(exports, out) {
|
|
247
|
+
if (exports.length === 1)
|
|
248
|
+
return [{ out, exported: exports[0] }];
|
|
249
|
+
if (extname(out).toLowerCase() === ".csv") {
|
|
250
|
+
throw new Error(`CSV export matched ${exports.length} charts; use --chart-id to select one chart or pass a directory to --out`);
|
|
251
|
+
}
|
|
252
|
+
return exports.map((exported, index) => ({
|
|
253
|
+
out: join(out, `${String(index + 1).padStart(2, "0")}-${reportCsvBaseName(exported, index)}.csv`),
|
|
254
|
+
exported,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
function reportCsvBaseName(exported, index) {
|
|
258
|
+
const candidate = exported.chartId || exported.reportId || `report-${index + 1}`;
|
|
259
|
+
return candidate
|
|
260
|
+
.replace(/[^A-Za-z0-9_.-]+/g, "-")
|
|
261
|
+
.replace(/^-+|-+$/g, "")
|
|
262
|
+
|| `report-${index + 1}`;
|
|
263
|
+
}
|
|
264
|
+
function warnTruncatedCsv(exported) {
|
|
265
|
+
if (!exported.truncated)
|
|
266
|
+
return;
|
|
267
|
+
process.stderr.write(`Warning: CSV contains a truncated report result${exported.rowLimit ? ` (limit ${exported.rowLimit})` : ""}.\n`);
|
|
136
268
|
}
|
|
137
269
|
export async function resolveCliReportPlatform(client, flagValue, selectorValue, startDate, endDate) {
|
|
138
270
|
const flagPlatform = optionalCliReportPlatform(flagValue, "--platform");
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { objectValue } from "./resources.js";
|
|
2
|
+
export function diffReportPackEventCatalog(reportPackValue, catalogResponseValue) {
|
|
3
|
+
const reportPack = objectValue(reportPackValue);
|
|
4
|
+
if (!reportPack)
|
|
5
|
+
throw new Error("report pack must be a JSON object");
|
|
6
|
+
const reportEvents = reportPack.events === undefined ? {} : objectValue(reportPack.events);
|
|
7
|
+
if (!reportEvents)
|
|
8
|
+
throw new Error("report pack events must be an object");
|
|
9
|
+
const catalogResponse = objectValue(catalogResponseValue);
|
|
10
|
+
if (!catalogResponse || !Array.isArray(catalogResponse.events)) {
|
|
11
|
+
throw new Error("event catalog response is missing events");
|
|
12
|
+
}
|
|
13
|
+
const catalogEvents = catalogResponse.events.flatMap((rawEvent) => {
|
|
14
|
+
const event = objectValue(rawEvent);
|
|
15
|
+
const eventType = stringValue(event?.eventType);
|
|
16
|
+
return event && eventType ? [{ eventType, event }] : [];
|
|
17
|
+
});
|
|
18
|
+
const catalogByType = new Map(catalogEvents.map(({ eventType, event }) => [eventType, event]));
|
|
19
|
+
const errors = [];
|
|
20
|
+
const items = [];
|
|
21
|
+
let missingEventCount = 0;
|
|
22
|
+
let missingFieldCount = 0;
|
|
23
|
+
let typeConflictCount = 0;
|
|
24
|
+
let blockedEventCount = 0;
|
|
25
|
+
let deprecatedEventCount = 0;
|
|
26
|
+
for (const [eventType, rawReportEvent] of Object.entries(reportEvents).sort(([left], [right]) => left.localeCompare(right))) {
|
|
27
|
+
const reportEvent = objectValue(rawReportEvent);
|
|
28
|
+
const reportFields = objectValue(reportEvent?.fields) ?? {};
|
|
29
|
+
const catalogEvent = catalogByType.get(eventType);
|
|
30
|
+
const missingFields = [];
|
|
31
|
+
const typeConflicts = [];
|
|
32
|
+
if (!catalogEvent) {
|
|
33
|
+
missingEventCount += 1;
|
|
34
|
+
for (const [fieldId, rawReportField] of Object.entries(reportFields).sort(([left], [right]) => left.localeCompare(right))) {
|
|
35
|
+
missingFields.push({ id: fieldId, type: stringValue(objectValue(rawReportField)?.type) });
|
|
36
|
+
}
|
|
37
|
+
missingFieldCount += missingFields.length;
|
|
38
|
+
errors.push({ scope: `event:${eventType}`, message: "Event is not registered in the event catalog" });
|
|
39
|
+
items.push({
|
|
40
|
+
eventType,
|
|
41
|
+
catalogStatus: "missing",
|
|
42
|
+
...(missingFields.length ? { missingFields } : {}),
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const catalogStatus = stringValue(catalogEvent.status) || "active";
|
|
47
|
+
if (catalogStatus === "blocked") {
|
|
48
|
+
blockedEventCount += 1;
|
|
49
|
+
errors.push({ scope: `event:${eventType}`, message: "Event is blocked in the event catalog" });
|
|
50
|
+
}
|
|
51
|
+
else if (catalogStatus === "deprecated") {
|
|
52
|
+
deprecatedEventCount += 1;
|
|
53
|
+
errors.push({ scope: `event:${eventType}`, message: "Event is deprecated in the event catalog" });
|
|
54
|
+
}
|
|
55
|
+
const catalogFields = objectValue(catalogEvent.fields) ?? {};
|
|
56
|
+
for (const [fieldId, rawReportField] of Object.entries(reportFields).sort(([left], [right]) => left.localeCompare(right))) {
|
|
57
|
+
const reportType = stringValue(objectValue(rawReportField)?.type);
|
|
58
|
+
const catalogField = objectValue(catalogFields[fieldId]);
|
|
59
|
+
if (!catalogField) {
|
|
60
|
+
missingFields.push({ id: fieldId, type: reportType });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const catalogType = stringValue(catalogField.type);
|
|
64
|
+
if (reportType && catalogType && reportType !== catalogType) {
|
|
65
|
+
typeConflicts.push({ id: fieldId, reportType, catalogType });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
missingFieldCount += missingFields.length;
|
|
69
|
+
typeConflictCount += typeConflicts.length;
|
|
70
|
+
if (missingFields.length) {
|
|
71
|
+
errors.push({
|
|
72
|
+
scope: `event:${eventType}`,
|
|
73
|
+
message: `${missingFields.length} report field(s) are not registered in the event catalog`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (typeConflicts.length) {
|
|
77
|
+
errors.push({
|
|
78
|
+
scope: `event:${eventType}`,
|
|
79
|
+
message: `${typeConflicts.length} report field type(s) conflict with the event catalog`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (catalogStatus !== "active" || missingFields.length || typeConflicts.length) {
|
|
83
|
+
items.push({
|
|
84
|
+
eventType,
|
|
85
|
+
catalogStatus,
|
|
86
|
+
...(missingFields.length ? { missingFields } : {}),
|
|
87
|
+
...(typeConflicts.length ? { typeConflicts } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
command: "events.diff",
|
|
94
|
+
ok: errors.length === 0,
|
|
95
|
+
summary: {
|
|
96
|
+
reportEventCount: Object.keys(reportEvents).length,
|
|
97
|
+
catalogEventCount: catalogEvents.length,
|
|
98
|
+
issueEventCount: items.length,
|
|
99
|
+
missingEventCount,
|
|
100
|
+
missingFieldCount,
|
|
101
|
+
typeConflictCount,
|
|
102
|
+
blockedEventCount,
|
|
103
|
+
deprecatedEventCount,
|
|
104
|
+
},
|
|
105
|
+
errors,
|
|
106
|
+
warnings: [],
|
|
107
|
+
items,
|
|
108
|
+
truncated: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function stringValue(value) {
|
|
112
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
113
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { formatDuration } from "./output.js";
|
|
2
2
|
import { normalizeHost } from "./utils.js";
|
|
3
|
+
export class HttpRequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
errorCode;
|
|
6
|
+
details;
|
|
7
|
+
constructor(status, message, errorCode, details) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "HttpRequestError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.errorCode = errorCode;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
3
15
|
export async function requestJson(host, path, init) {
|
|
4
16
|
const { timeoutMs, ...requestInit } = init;
|
|
5
17
|
let response;
|
|
@@ -18,13 +30,30 @@ export async function requestJson(host, path, init) {
|
|
|
18
30
|
const text = await response.text();
|
|
19
31
|
const json = parseJsonResponse(text);
|
|
20
32
|
if (!response.ok) {
|
|
21
|
-
|
|
33
|
+
const body = json && typeof json === "object" && !Array.isArray(json) ? json : undefined;
|
|
34
|
+
const errorCode = typeof body?.error === "string" ? body.error : undefined;
|
|
35
|
+
const details = body?.details && typeof body.details === "object" && !Array.isArray(body.details)
|
|
36
|
+
? body.details
|
|
37
|
+
: undefined;
|
|
38
|
+
throw new HttpRequestError(response.status, formatHttpError(response.status, response.statusText, text, json), errorCode, details);
|
|
22
39
|
}
|
|
23
40
|
if (text && json === undefined) {
|
|
24
41
|
throw new Error(`Invalid JSON response from ${path}: ${truncateBody(text)}`);
|
|
25
42
|
}
|
|
26
43
|
return json ?? null;
|
|
27
44
|
}
|
|
45
|
+
export function cliErrorPayload(error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
if (!(error instanceof HttpRequestError))
|
|
48
|
+
return { ok: false, error: message };
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: message,
|
|
52
|
+
status: error.status,
|
|
53
|
+
...(error.errorCode ? { errorCode: error.errorCode } : {}),
|
|
54
|
+
...(error.details ? { details: error.details } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
28
57
|
export function parseJsonResponse(text) {
|
|
29
58
|
if (!text)
|
|
30
59
|
return null;
|
package/dist/main.js
CHANGED
|
@@ -12,6 +12,8 @@ import { handleFileResource } from "./commands/resources.js";
|
|
|
12
12
|
import { handleSkill } from "./commands/skill.js";
|
|
13
13
|
import { loadPackageMetadata } from "./config.js";
|
|
14
14
|
import { handleDocs, handleHelp } from "./docs.js";
|
|
15
|
+
import { handleDynamicConfig } from "./commands/dynamic-config.js";
|
|
16
|
+
import { cliErrorPayload } from "./http.js";
|
|
15
17
|
import { printResult } from "./output.js";
|
|
16
18
|
export async function main() {
|
|
17
19
|
const args = process.argv.slice(2);
|
|
@@ -76,6 +78,10 @@ export async function main() {
|
|
|
76
78
|
await handleFileResource(client, args, global, "config");
|
|
77
79
|
return;
|
|
78
80
|
}
|
|
81
|
+
if (command === "dynamic-config") {
|
|
82
|
+
await handleDynamicConfig(client, args, global);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
79
85
|
if (command === "report") {
|
|
80
86
|
await handleReport(client, args, global);
|
|
81
87
|
return;
|
|
@@ -105,7 +111,7 @@ export async function main() {
|
|
|
105
111
|
catch (error) {
|
|
106
112
|
const message = error instanceof Error ? error.message : String(error);
|
|
107
113
|
if (global.json) {
|
|
108
|
-
console.log(JSON.stringify(
|
|
114
|
+
console.log(JSON.stringify(cliErrorPayload(error), null, 2));
|
|
109
115
|
}
|
|
110
116
|
else {
|
|
111
117
|
console.error(`Error: ${message}`);
|
package/dist/output.js
CHANGED
|
@@ -62,6 +62,7 @@ export function formatDuration(ms) {
|
|
|
62
62
|
export async function printResult(value, global) {
|
|
63
63
|
if (global.json) {
|
|
64
64
|
console.log(JSON.stringify(value, null, 2));
|
|
65
|
+
markExplicitFailure(value);
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
68
|
if (typeof value === "string") {
|
|
@@ -69,6 +70,15 @@ export async function printResult(value, global) {
|
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
72
|
console.log(YAML.stringify(value).trimEnd());
|
|
73
|
+
markExplicitFailure(value);
|
|
74
|
+
}
|
|
75
|
+
export function markCommandFailed() {
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
function markExplicitFailure(value) {
|
|
79
|
+
if (value && typeof value === "object" && !Array.isArray(value) && value.ok === false) {
|
|
80
|
+
markCommandFailed();
|
|
81
|
+
}
|
|
72
82
|
}
|
|
73
83
|
export async function confirm(question) {
|
|
74
84
|
const rl = createInterface({ input, output });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function reportDashboardCsv(value) {
|
|
2
|
+
const exports = reportDashboardCsvExports(value);
|
|
3
|
+
if (exports.length !== 1) {
|
|
4
|
+
throw new Error(`CSV output to stdout or one file requires exactly one matching chart, but the query returned ${exports.length}`);
|
|
5
|
+
}
|
|
6
|
+
return exports[0];
|
|
7
|
+
}
|
|
8
|
+
export function reportDashboardCsvExports(value) {
|
|
9
|
+
const dashboard = recordValue(value);
|
|
10
|
+
const results = Array.isArray(dashboard?.results) ? dashboard.results : [];
|
|
11
|
+
if (!results.length)
|
|
12
|
+
throw new Error("CSV export did not receive any matching chart results");
|
|
13
|
+
return results.map(reportResultCsv);
|
|
14
|
+
}
|
|
15
|
+
function reportResultCsv(value) {
|
|
16
|
+
const result = recordValue(value);
|
|
17
|
+
if (!result)
|
|
18
|
+
throw new Error("CSV export received an invalid report result");
|
|
19
|
+
if (typeof result.error === "string" && result.error.trim()) {
|
|
20
|
+
throw new Error(`cannot export failed report result: ${result.error.trim()}`);
|
|
21
|
+
}
|
|
22
|
+
const rows = Array.isArray(result.rows)
|
|
23
|
+
? result.rows.map((row) => recordValue(row) || {})
|
|
24
|
+
: [];
|
|
25
|
+
const declaredColumns = Array.isArray(result.columns)
|
|
26
|
+
? result.columns.filter((column) => typeof column === "string" && Boolean(column.trim()))
|
|
27
|
+
: [];
|
|
28
|
+
const columns = uniqueStrings(declaredColumns.length ? declaredColumns : rows.flatMap((row) => Object.keys(row)));
|
|
29
|
+
if (!columns.length) {
|
|
30
|
+
throw new Error("CSV export result is not ready because the Server returned no columns; retry with --refresh or wait for the background refresh");
|
|
31
|
+
}
|
|
32
|
+
const lines = [
|
|
33
|
+
columns.map(csvCell).join(","),
|
|
34
|
+
...rows.map((row) => columns.map((column) => csvCell(row[column])).join(",")),
|
|
35
|
+
];
|
|
36
|
+
const chart = recordValue(result.chart);
|
|
37
|
+
const rowLimit = positiveIntegerOrUndefined(result.rowLimit);
|
|
38
|
+
return {
|
|
39
|
+
content: `\uFEFF${lines.join("\r\n")}\r\n`,
|
|
40
|
+
columns,
|
|
41
|
+
rowCount: rows.length,
|
|
42
|
+
reportId: optionalString(result.reportId),
|
|
43
|
+
chartId: optionalString(chart?.id),
|
|
44
|
+
chartTitle: optionalString(chart?.title),
|
|
45
|
+
truncated: result.truncated === true,
|
|
46
|
+
...(rowLimit ? { rowLimit } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function csvCell(value) {
|
|
50
|
+
let text;
|
|
51
|
+
if (value === null || value === undefined)
|
|
52
|
+
text = "";
|
|
53
|
+
else if (typeof value === "string")
|
|
54
|
+
text = protectSpreadsheetFormula(value);
|
|
55
|
+
else if (typeof value === "number")
|
|
56
|
+
text = Number.isFinite(value) ? String(value) : "";
|
|
57
|
+
else if (typeof value === "boolean" || typeof value === "bigint")
|
|
58
|
+
text = String(value);
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
text = JSON.stringify(value) ?? "";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
text = String(value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (/[",\r\n]/.test(text) || /^\s|\s$/.test(text))
|
|
68
|
+
return `"${text.replaceAll('"', '""')}"`;
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
function protectSpreadsheetFormula(value) {
|
|
72
|
+
return /^\s*[=+\-@]/.test(value) ? `'${value}` : value;
|
|
73
|
+
}
|
|
74
|
+
function recordValue(value) {
|
|
75
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
76
|
+
? value
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
function uniqueStrings(values) {
|
|
80
|
+
return [...new Set(values)];
|
|
81
|
+
}
|
|
82
|
+
function optionalString(value) {
|
|
83
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
84
|
+
}
|
|
85
|
+
function positiveIntegerOrUndefined(value) {
|
|
86
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
87
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
export const REPORT_SUMMARY_SCHEMA_VERSION = 1;
|
|
2
|
+
export const REPORT_SUMMARY_MAX_ITEMS = 200;
|
|
3
|
+
export const REPORT_SUMMARY_MAX_ISSUES = 100;
|
|
4
|
+
export const REPORT_SUMMARY_MAX_SAMPLE_ROWS = 20;
|
|
5
|
+
export function summarizeReportValidation(value) {
|
|
6
|
+
const root = objectValue(value);
|
|
7
|
+
const validation = objectValue(root?.validation);
|
|
8
|
+
const errorMessages = stringValues(validation?.errors);
|
|
9
|
+
const warningMessages = stringValues(validation?.warnings);
|
|
10
|
+
const statements = arrayValues(objectValue(root?.sqlPreview)?.statements);
|
|
11
|
+
const statementItems = statements.map((raw) => {
|
|
12
|
+
const statement = objectValue(raw);
|
|
13
|
+
const sql = typeof statement?.sql === "string" ? statement.sql : "";
|
|
14
|
+
const declaredBytes = finiteNonNegativeNumber(statement?.bytes);
|
|
15
|
+
return compactObject({
|
|
16
|
+
kind: "sql",
|
|
17
|
+
id: stringValue(statement?.id),
|
|
18
|
+
title: stringValue(statement?.title),
|
|
19
|
+
bytes: declaredBytes ?? utf8Bytes(sql),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
const errors = issueValues(errorMessages, "validation");
|
|
23
|
+
const warnings = issueValues(warningMessages, "validation");
|
|
24
|
+
const items = statementItems.slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
27
|
+
command: "report.validate",
|
|
28
|
+
ok: validation?.ok === true && errorMessages.length === 0,
|
|
29
|
+
summary: {
|
|
30
|
+
errorCount: errorMessages.length,
|
|
31
|
+
warningCount: warningMessages.length,
|
|
32
|
+
eventTypeCount: arrayValues(validation?.eventTypes).length,
|
|
33
|
+
datasetCount: arrayValues(validation?.datasets).length,
|
|
34
|
+
reportCount: arrayValues(validation?.reports).length,
|
|
35
|
+
sqlStatementCount: statements.length,
|
|
36
|
+
},
|
|
37
|
+
errors: errors.values,
|
|
38
|
+
warnings: warnings.values,
|
|
39
|
+
items,
|
|
40
|
+
truncated: errors.truncated || warnings.truncated || statementItems.length > items.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function summarizeReportManifest(value) {
|
|
44
|
+
const root = objectValue(value);
|
|
45
|
+
const reportPack = compactReportPack(root?.reportPack);
|
|
46
|
+
const manifest = objectValue(root?.manifest);
|
|
47
|
+
const tabs = arrayValues(manifest?.tabs);
|
|
48
|
+
const experimentViews = arrayValues(manifest?.experimentViews);
|
|
49
|
+
const reportIds = arrayValues(manifest?.reports)
|
|
50
|
+
.map((report) => typeof report === "string" ? report : stringValue(objectValue(report)?.id))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
const returnedReportIds = [...new Set(reportIds)].slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
53
|
+
const allItems = [];
|
|
54
|
+
let groupCount = 0;
|
|
55
|
+
let chartCount = 0;
|
|
56
|
+
for (const rawTab of tabs) {
|
|
57
|
+
const tab = objectValue(rawTab);
|
|
58
|
+
const groups = arrayValues(tab?.groups);
|
|
59
|
+
groupCount += groups.length;
|
|
60
|
+
for (const rawGroup of groups) {
|
|
61
|
+
const group = objectValue(rawGroup);
|
|
62
|
+
const charts = arrayValues(group?.charts);
|
|
63
|
+
chartCount += charts.length;
|
|
64
|
+
for (const rawChart of charts) {
|
|
65
|
+
allItems.push(chartManifestItem(rawChart, group, {
|
|
66
|
+
scope: "dashboard",
|
|
67
|
+
tabId: stringValue(tab?.id),
|
|
68
|
+
tabTitle: stringValue(tab?.title),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const rawView of experimentViews) {
|
|
74
|
+
const view = objectValue(rawView);
|
|
75
|
+
const groups = arrayValues(view?.groups);
|
|
76
|
+
groupCount += groups.length;
|
|
77
|
+
for (const rawGroup of groups) {
|
|
78
|
+
const group = objectValue(rawGroup);
|
|
79
|
+
const charts = arrayValues(group?.charts);
|
|
80
|
+
chartCount += charts.length;
|
|
81
|
+
for (const rawChart of charts) {
|
|
82
|
+
allItems.push(chartManifestItem(rawChart, group, {
|
|
83
|
+
scope: "experiment",
|
|
84
|
+
strategy: stringValue(view?.strategy),
|
|
85
|
+
viewTitle: stringValue(view?.title),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const items = allItems.slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
93
|
+
command: "report.manifest",
|
|
94
|
+
ok: Boolean(manifest),
|
|
95
|
+
summary: compactObject({
|
|
96
|
+
reportPack,
|
|
97
|
+
title: stringValue(manifest?.title),
|
|
98
|
+
tabCount: tabs.length,
|
|
99
|
+
experimentViewCount: experimentViews.length,
|
|
100
|
+
groupCount,
|
|
101
|
+
chartCount,
|
|
102
|
+
reportCount: reportIds.length,
|
|
103
|
+
reportIds: returnedReportIds,
|
|
104
|
+
returnedItemCount: items.length,
|
|
105
|
+
}),
|
|
106
|
+
errors: manifest ? [] : [{ scope: "manifest", message: "Report manifest response is missing manifest" }],
|
|
107
|
+
warnings: [],
|
|
108
|
+
items,
|
|
109
|
+
truncated: allItems.length > items.length || reportIds.length > returnedReportIds.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function summarizeReportDashboard(value, options) {
|
|
113
|
+
const root = objectValue(value);
|
|
114
|
+
const results = arrayValues(root?.results);
|
|
115
|
+
const requestedSampleRows = normalizeReportSampleRows(options.sampleRows);
|
|
116
|
+
const allErrors = [];
|
|
117
|
+
const allWarnings = [];
|
|
118
|
+
const allItems = [];
|
|
119
|
+
const statusCounts = {};
|
|
120
|
+
let totalRows = 0;
|
|
121
|
+
let sourceTruncated = false;
|
|
122
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
123
|
+
const result = objectValue(results[index]);
|
|
124
|
+
const chart = objectValue(result?.chart);
|
|
125
|
+
const tab = objectValue(result?.tab);
|
|
126
|
+
const group = objectValue(result?.group);
|
|
127
|
+
const chartId = stringValue(chart?.id) || `result-${index + 1}`;
|
|
128
|
+
const scope = `chart:${chartId}`;
|
|
129
|
+
const rows = arrayValues(result?.rows);
|
|
130
|
+
const rowCount = finiteNonNegativeNumber(result?.rowCount) ?? rows.length;
|
|
131
|
+
const status = reportResultStatus(result);
|
|
132
|
+
const error = stringValue(result?.error);
|
|
133
|
+
const truncated = result?.truncated === true;
|
|
134
|
+
totalRows += rowCount;
|
|
135
|
+
statusCounts[status] = (statusCounts[status] ?? 0) + 1;
|
|
136
|
+
if (error)
|
|
137
|
+
allErrors.push({ scope, message: error });
|
|
138
|
+
if (truncated) {
|
|
139
|
+
sourceTruncated = true;
|
|
140
|
+
const rowLimit = finiteNonNegativeNumber(result?.rowLimit);
|
|
141
|
+
allWarnings.push({
|
|
142
|
+
scope,
|
|
143
|
+
message: `Report result is truncated${rowLimit === undefined ? "" : ` at ${rowLimit} rows`}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (result?.stale === true) {
|
|
147
|
+
allWarnings.push({ scope, message: "Report result is stale" });
|
|
148
|
+
}
|
|
149
|
+
allItems.push(compactObject({
|
|
150
|
+
kind: "chart-result",
|
|
151
|
+
status,
|
|
152
|
+
tabId: stringValue(tab?.id),
|
|
153
|
+
tabTitle: stringValue(tab?.title),
|
|
154
|
+
groupId: stringValue(group?.id),
|
|
155
|
+
groupTitle: stringValue(group?.title),
|
|
156
|
+
chartId,
|
|
157
|
+
chartTitle: stringValue(chart?.title),
|
|
158
|
+
chartType: stringValue(chart?.type),
|
|
159
|
+
reportId: stringValue(result?.reportId),
|
|
160
|
+
columns: columnNames(result?.columns),
|
|
161
|
+
rowCount,
|
|
162
|
+
cached: typeof result?.cached === "boolean" ? result.cached : undefined,
|
|
163
|
+
cachedAt: stringValue(result?.cachedAt),
|
|
164
|
+
stale: typeof result?.stale === "boolean" ? result.stale : undefined,
|
|
165
|
+
truncated,
|
|
166
|
+
rowLimit: finiteNonNegativeNumber(result?.rowLimit),
|
|
167
|
+
error,
|
|
168
|
+
sampleRows: requestedSampleRows > 0 ? rows.slice(0, requestedSampleRows) : undefined,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
const errors = limitIssues(allErrors);
|
|
172
|
+
const warnings = limitIssues(allWarnings);
|
|
173
|
+
const items = allItems.slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
174
|
+
const incompleteCount = Object.entries(statusCounts)
|
|
175
|
+
.filter(([status]) => status !== "ready")
|
|
176
|
+
.reduce((sum, [, count]) => sum + count, 0);
|
|
177
|
+
return {
|
|
178
|
+
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
179
|
+
command: options.command,
|
|
180
|
+
ok: allErrors.length === 0 && incompleteCount === 0,
|
|
181
|
+
summary: compactObject({
|
|
182
|
+
reportPack: compactReportPack(root?.reportPack),
|
|
183
|
+
dateRange: objectValue(root?.dateRange),
|
|
184
|
+
selectors: objectValue(root?.selectors),
|
|
185
|
+
resultCount: results.length,
|
|
186
|
+
statusCounts,
|
|
187
|
+
totalRows,
|
|
188
|
+
sampleRowsPerResult: requestedSampleRows,
|
|
189
|
+
returnedItemCount: items.length,
|
|
190
|
+
cli: objectValue(root?.cli),
|
|
191
|
+
}),
|
|
192
|
+
errors: errors.values,
|
|
193
|
+
warnings: warnings.values,
|
|
194
|
+
items,
|
|
195
|
+
truncated: sourceTruncated || errors.truncated || warnings.truncated || allItems.length > items.length,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function normalizeReportSampleRows(value) {
|
|
199
|
+
if (value === undefined || value === null || value === "")
|
|
200
|
+
return 0;
|
|
201
|
+
if (typeof value === "boolean")
|
|
202
|
+
throw new Error("--sample-rows requires a non-negative integer");
|
|
203
|
+
const parsed = Number(value);
|
|
204
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > REPORT_SUMMARY_MAX_SAMPLE_ROWS) {
|
|
205
|
+
throw new Error(`--sample-rows must be an integer between 0 and ${REPORT_SUMMARY_MAX_SAMPLE_ROWS}`);
|
|
206
|
+
}
|
|
207
|
+
return parsed;
|
|
208
|
+
}
|
|
209
|
+
function chartManifestItem(rawChart, group, location) {
|
|
210
|
+
const chart = objectValue(rawChart);
|
|
211
|
+
const views = objectValue(chart?.views);
|
|
212
|
+
const reportIds = new Set();
|
|
213
|
+
const reportId = stringValue(chart?.report);
|
|
214
|
+
if (reportId)
|
|
215
|
+
reportIds.add(reportId);
|
|
216
|
+
for (const rawView of Object.values(views ?? {})) {
|
|
217
|
+
const viewReportId = stringValue(objectValue(rawView)?.report);
|
|
218
|
+
if (viewReportId)
|
|
219
|
+
reportIds.add(viewReportId);
|
|
220
|
+
}
|
|
221
|
+
return compactObject({
|
|
222
|
+
kind: "chart",
|
|
223
|
+
...location,
|
|
224
|
+
groupId: stringValue(group?.id),
|
|
225
|
+
groupTitle: stringValue(group?.title),
|
|
226
|
+
standardRef: stringValue(group?.standardRef),
|
|
227
|
+
chartId: stringValue(chart?.id),
|
|
228
|
+
chartTitle: stringValue(chart?.title),
|
|
229
|
+
chartType: stringValue(chart?.type),
|
|
230
|
+
reportIds: [...reportIds],
|
|
231
|
+
selectorIds: arrayValues(group?.selectors)
|
|
232
|
+
.map((selector) => stringValue(objectValue(selector)?.id))
|
|
233
|
+
.filter(Boolean),
|
|
234
|
+
viewIds: views ? Object.keys(views) : [],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function reportResultStatus(result) {
|
|
238
|
+
if (stringValue(result?.error))
|
|
239
|
+
return "error";
|
|
240
|
+
if (result?.pending === true)
|
|
241
|
+
return "pending";
|
|
242
|
+
if (result?.retrying === true)
|
|
243
|
+
return "retrying";
|
|
244
|
+
if (result?.refreshing === true)
|
|
245
|
+
return "refreshing";
|
|
246
|
+
if (result?.stale === true)
|
|
247
|
+
return "stale";
|
|
248
|
+
return "ready";
|
|
249
|
+
}
|
|
250
|
+
function compactReportPack(value) {
|
|
251
|
+
const reportPack = objectValue(value);
|
|
252
|
+
if (!reportPack)
|
|
253
|
+
return undefined;
|
|
254
|
+
return compactObject({
|
|
255
|
+
gameId: stringValue(reportPack.gameId),
|
|
256
|
+
version: stringValue(reportPack.version),
|
|
257
|
+
status: stringValue(reportPack.status),
|
|
258
|
+
updatedAt: stringValue(reportPack.updatedAt),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function columnNames(value) {
|
|
262
|
+
return arrayValues(value).flatMap((column) => {
|
|
263
|
+
if (typeof column === "string")
|
|
264
|
+
return column ? [column] : [];
|
|
265
|
+
const object = objectValue(column);
|
|
266
|
+
const name = stringValue(object?.name) || stringValue(object?.id);
|
|
267
|
+
return name ? [name] : [];
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function issueValues(messages, scope) {
|
|
271
|
+
return limitIssues(messages.map((message) => ({ scope, message })));
|
|
272
|
+
}
|
|
273
|
+
function limitIssues(values) {
|
|
274
|
+
return {
|
|
275
|
+
values: values.slice(0, REPORT_SUMMARY_MAX_ISSUES),
|
|
276
|
+
truncated: values.length > REPORT_SUMMARY_MAX_ISSUES,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function objectValue(value) {
|
|
280
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
281
|
+
}
|
|
282
|
+
function arrayValues(value) {
|
|
283
|
+
return Array.isArray(value) ? value : [];
|
|
284
|
+
}
|
|
285
|
+
function stringValues(value) {
|
|
286
|
+
return arrayValues(value).filter((item) => typeof item === "string");
|
|
287
|
+
}
|
|
288
|
+
function stringValue(value) {
|
|
289
|
+
return typeof value === "string" && value ? value : undefined;
|
|
290
|
+
}
|
|
291
|
+
function finiteNonNegativeNumber(value) {
|
|
292
|
+
const number = Number(value);
|
|
293
|
+
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
|
294
|
+
}
|
|
295
|
+
function compactObject(value) {
|
|
296
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
297
|
+
}
|
|
298
|
+
function utf8Bytes(value) {
|
|
299
|
+
return new TextEncoder().encode(value).byteLength;
|
|
300
|
+
}
|
package/dist/utils.js
CHANGED
|
@@ -14,6 +14,12 @@ export function integrationPlatform(value) {
|
|
|
14
14
|
return platform;
|
|
15
15
|
throw new Error("--platform must be ios, android, maker, or rest");
|
|
16
16
|
}
|
|
17
|
+
export function experimentPlatform(value) {
|
|
18
|
+
const platform = optionalString(value);
|
|
19
|
+
if (platform === "ios" || platform === "android" || platform === "rest")
|
|
20
|
+
return platform;
|
|
21
|
+
throw new Error("--platform must be ios, android, or rest for experiments");
|
|
22
|
+
}
|
|
17
23
|
export function optionalString(value) {
|
|
18
24
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
19
25
|
}
|
package/package.json
CHANGED
package/skills/gamealgo/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gamealgo
|
|
3
|
-
description: Integrate and improve games with GameAlgo using the project-bound CLI, live Server documentation, and the Server-maintained optimization task catalog. Use when working on GameAlgo SDK setup, telemetry events, report packs, Adjust attribution, remote strategies, experiments,
|
|
3
|
+
description: Integrate and improve games with GameAlgo using the project-bound CLI, live Server documentation, and the Server-maintained optimization task catalog. Use when working on GameAlgo SDK setup, telemetry events, report packs, Adjust attribution, remote strategies, experiments, discovering supported GameAlgo iteration tasks, or changing player-facing game behavior that may need telemetry coverage.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# GameAlgo
|
|
@@ -37,11 +37,17 @@ Use each topic's section list as the router. Read the whole topic only when the
|
|
|
37
37
|
- Calling or changing a concrete public SDK function: additionally run `gamealgo docs api <function> --platform <platform>` for each affected function. Never infer one platform's API from another.
|
|
38
38
|
- Adding or changing events: additionally run `gamealgo events list --days 30 --json` before defining fields or names.
|
|
39
39
|
- Querying an existing dashboard or report: read `gamealgo docs reports --section report-query` plus the section that defines the relevant metric or template. Creating, changing, previewing, or publishing Report Packs, charts, report SQL, or business metrics: list the `reports` sections and read every section affected by the change before interpreting data or editing definitions.
|
|
40
|
-
- Creating or changing remote configuration, strategies, experiments, variants, objectives, rollout, or assessment: list the `experiments` sections and read every section affected by the change; also load the integration plan and exact API documentation when client code changes.
|
|
40
|
+
- Creating or changing remote configuration, dynamic config pipelines, strategies, experiments, variants, objectives, rollout, or assessment: list the `experiments` sections and read every section affected by the change; also load the integration plan and exact API documentation when client code changes.
|
|
41
41
|
- Adjust or marketing attribution work: load the selected optimization task when one applies, read every topic in its `docs`, and load the integration plan and exact identifier/attribution APIs for the target platform.
|
|
42
42
|
|
|
43
43
|
If work crosses domains, combine their requirements. For example, an advertising event used by an experiment report requires the integration plan, event inventory, SDK API documentation, relevant report sections, and relevant experiment sections. Read a section once per turn unless the task expands or the Server documentation version changes.
|
|
44
44
|
|
|
45
|
+
For `report validate`, `report manifest`, `report preview`, and JSON `report result`, use the default compact output. It provides the stable `schemaVersion`, `command`, `ok`, `summary`, `errors`, `warnings`, `items`, and `truncated` fields needed by an agent; do not recreate summaries with ad-hoc `jq`. Add `--sample-rows <n>` only when a small value sample is necessary. Use `--full` only when the task genuinely requires complete generated SQL, chart definitions, or every returned row; use CSV export rather than full JSON when the goal is data extraction.
|
|
46
|
+
|
|
47
|
+
When changing an existing Report Pack, run `gamealgo report pull --out <working-pack.json>` before editing and use that current published Pack as the baseline. Do not start from a remembered or stale local copy. Run `gamealgo events diff --pack <working-pack.json>` after defining the Pack events; if it reports missing fields, type conflicts, or blocked/deprecated events, inspect the actual game call sites and prepare real business descriptions rather than inventing catalog text from field names. The diff is read-only and does not authorize `events apply` or event activation.
|
|
48
|
+
|
|
49
|
+
Validate with the compact default output, then preview every chart or group affected by the change without querying unrelated tabs. A report query must explicitly select a tab, group, or chart. A globally unique bare chart id is accepted; if the Server returns `ambiguous_chart_id`, use its candidate ids and the local Pack structure to retry without asking the developer to choose an internal chart id. Before applying event metadata or publishing the Pack, show the exact changes and impact unless the developer already authorized them. After publishing, pull the effective Pack again and verify the expected manifest or report result.
|
|
50
|
+
|
|
45
51
|
For initial integration or business dashboard work, standard dashboards are only the baseline. During the initial integration, inspect the actual game loop and define a separate game-specific new-user flow dashboard with meaningful milestones and drop-off diagnostics. Add required milestone telemetry and event catalog fields in the same integration instead of waiting for production volume. Validate the pipeline with test data; label business conclusions as awaiting data when production samples are immature, but do not omit the dashboard design.
|
|
46
52
|
|
|
47
53
|
## Follow Mandatory Checkpoints
|
|
@@ -53,6 +59,18 @@ For initial integration or business dashboard work, standard dashboards are only
|
|
|
53
59
|
5. Before publishing reports, remote configuration, scripts, strategies, experiments, rollout changes, or other externally visible mutations, show the relevant diff and impact unless the user has already explicitly authorized that exact operation.
|
|
54
60
|
6. After execution, verify with real configuration retrieval, accepted non-debug events, report results, or experiment state as appropriate. A code diff or successful request alone is not business verification.
|
|
55
61
|
|
|
62
|
+
## Keep Production Analysis Off The Developer Workstation
|
|
63
|
+
|
|
64
|
+
For production telemetry investigations, execute partition-scale scans and aggregations with managed Alibaba Cloud compute such as DataWorks/MaxCompute or Kyuubi/Spark, and return only the small aggregated result needed for diagnosis.
|
|
65
|
+
|
|
66
|
+
The built-in domestic Data Bridge Paimon backend is not a distributed SQL pushdown engine: PyPaimon plans Paimon splits, reads OSS data into an Arrow table inside the Bridge pod, and only then executes the SQL with DuckDB. The current runner also does not translate SQL predicates into `read_builder.with_filter(...)`, so SQL `dt`, `game`, or `platform` filters do not guarantee Paimon split pruning. Do not use this backend for production raw/enriched partition scans or describe it as Paimon aggregating inside the storage service. Limit it to tightly bounded summary-table reads or emergency diagnostics until the query path is backed by managed compute.
|
|
67
|
+
|
|
68
|
+
- Never use the local PyPaimon/DuckDB launcher to calculate production counts, DAU, hourly volume, event coverage, or source-to-enriched reconciliation. That launcher materializes selected Paimon partition data on the local machine even when the submitted SQL contains only aggregates.
|
|
69
|
+
- Do not pull production detail rows or whole partitions to the local workspace and aggregate them afterward.
|
|
70
|
+
- Put partition filters, game filters, grouping, distinct counts, joins, and same-elapsed-window comparisons in the SQL sent to the managed query engine, and verify its execution plan actually prunes partitions and columns.
|
|
71
|
+
- Local Paimon access is limited to catalog/schema/partition metadata and a deliberately tiny `LIMIT` sample when the schema cannot otherwise be established.
|
|
72
|
+
- If no managed query path is available, stop and give the developer a bounded SQL query to run. Do not silently fall back to a local or Bridge-Pod partition scan.
|
|
73
|
+
|
|
56
74
|
Reading documentation does not grant permission to mutate production. CLI and Server validation, scopes, project binding, and explicit approval requirements still apply.
|
|
57
75
|
|
|
58
76
|
## Integration Details
|
|
@@ -69,6 +87,51 @@ Reuse events with the same business meaning, register new fields with their real
|
|
|
69
87
|
|
|
70
88
|
For every game, prefer stable, human-readable Chinese values for business dimensions that will appear directly in report legends, axes, selectors, or tables. This includes values such as level or stage type, game mode, placement, item name, and business outcome or failure reason. Keep event names, field names, IDs, SQL aliases, and protocol-defined enums in their required technical format. Do not rename values already used by a live game solely to translate them, because that would split one historical dimension into multiple report values.
|
|
71
89
|
|
|
90
|
+
## Revisit Telemetry When Gameplay Changes
|
|
91
|
+
|
|
92
|
+
When work adds or materially changes player-facing gameplay, onboarding, progression, economy, advertising, purchases, rewards, or important success and failure paths, proactively compare the new behavior with the existing GameAlgo telemetry. Derive the affected behavior from the current conversation and repository diff; do not make the developer restate the feature in a separate event configuration or prompt.
|
|
93
|
+
|
|
94
|
+
- Do not add telemetry mechanically for visual-only changes, refactors, or behavior already covered by an event with the same semantics.
|
|
95
|
+
- If existing telemetry is sufficient, say so briefly and do not ask a redundant question.
|
|
96
|
+
- If a meaningful decision or funnel step would remain unobservable, present a concise proposal as soon as the gap is clear: the question it answers, the standard or existing event to reuse (or the new custom event), exact trigger boundary, essential fields, expected volume bound, and any report it enables. Ask whether to include that telemetry in the current change; do not silently expand a feature request into new data collection.
|
|
97
|
+
- If the developer explicitly asks to update or optimize telemetry from the current context, treat that as authorization to inspect all relevant changes in the conversation and implement one coherent local telemetry patch without asking them to enumerate the features again. It does not by itself authorize publishing event-catalog or Report Pack changes; disclose those exact external mutations and follow the normal publication checkpoint.
|
|
98
|
+
- When telemetry was already explicitly requested, implement it with the feature instead of asking again.
|
|
99
|
+
|
|
100
|
+
After approval, update the actual emission call sites and their normal-flow tests together. Include catalog fields or reporting changes only when they are needed for the stated question, and then follow the event-volume and publication safeguards below.
|
|
101
|
+
|
|
102
|
+
## Bound Custom Event Volume
|
|
103
|
+
|
|
104
|
+
Before adding or changing a custom event, prefer a protocol-standard event or an existing custom event with the same meaning. Keep the event type stable and put variable values in payload fields; never generate event types from item IDs, levels, timestamps, user input, or other runtime values.
|
|
105
|
+
|
|
106
|
+
Inspect every call site for render/update loops, timers, polling, transport retries, duplicate lifecycle callbacks, and re-entrant listeners. State the trigger boundary and estimate the maximum calls per user, session, context, and active user-day. Use the game's active-user estimate to describe the expected daily volume when it is available.
|
|
107
|
+
|
|
108
|
+
- Standard SDK semantic events are governed by their protocol semantics. Do not disguise custom telemetry as a standard event to bypass safeguards.
|
|
109
|
+
- The SDK limits custom events to 1,000 occurrences per `context × eventType`, 5,000 total occurrences per context, and 100 distinct custom event types per context.
|
|
110
|
+
- A custom event with an unbounded trigger or an expected rate above 1,000 calls per context requires explicit developer review before implementation. Explain the expected volume, storage and processing impact, and the guard that makes the trigger bounded.
|
|
111
|
+
- Never add client-side retries around `track`, rename an event after rejection, remove its custom-event prefix, declare it standard, raise a safety limit, or call a lower-level transport to bypass a quota.
|
|
112
|
+
- Treat an SDK `event_guard` diagnostic as evidence of a telemetry bug or unexpectedly high-frequency design. Find and fix the emission path before asking to change a limit.
|
|
113
|
+
|
|
114
|
+
Before delivery, report the estimated bound, normal-flow tests, the limiting loop or callback, and behavior once the local quota is reached.
|
|
115
|
+
|
|
116
|
+
## Treat Dynamic Config As A Production Publisher
|
|
117
|
+
|
|
118
|
+
A dynamic config pipeline is Server-side automation that turns aggregated Report Pack inputs into a live config file. It does not authorize SDK changes, detailed production-data downloads, or live publication by itself.
|
|
119
|
+
|
|
120
|
+
- Define bounded aggregate inputs with Report Pack syntax. Keep filters, joins, aggregation, and data reduction in the managed query rather than passing event detail to JavaScript.
|
|
121
|
+
- Keep transforms synchronous and deterministic. They may use only the supplied datasets, previous output, and scheduled metadata; never depend on network, filesystem, environment variables, wall-clock APIs, randomness, async functions, or Promise output.
|
|
122
|
+
- The output file name and content type belong to the reviewed pipeline definition. The script may return only file content and JSON diagnostics.
|
|
123
|
+
- Before publishing or materially updating an active pipeline, show the target game, input reports and windows, complete script diff, output file and content type, schedule, expected behavior, failure behavior, and user-facing or operational risks. Obtain explicit approval for that exact publication unless the user already gave it after seeing those details.
|
|
124
|
+
- Prefer a disabled pipeline or a local script test while details are still under discussion. After an authorized publish, trigger one run, inspect the persisted run result and generated file version, and confirm that an unchanged second run is hash-deduplicated before treating the automation as verified.
|
|
125
|
+
- Do not raise the 1 MiB script/input/output caps, the 5,000-row per-dataset cap, the 5-second execution cap, or the minimum 60-minute schedule to bypass a failing design. Reduce data in the Report Pack or simplify the transform.
|
|
126
|
+
|
|
127
|
+
Use `gamealgo dynamic-config list|get|publish|run|runs` for lifecycle operations. Dynamic config output continues through the ordinary SDK config-file delivery mechanism, so do not change client SDK code merely to consume a generated file that already uses the existing config API.
|
|
128
|
+
|
|
129
|
+
## Respect Blocked Events
|
|
130
|
+
|
|
131
|
+
A blocked event is a persistent ingestion safety state. You may inspect its diagnostics, analyze data read-only, fix the client call site, and run local validation, but you must not reactivate it, register an equivalent replacement event, declare it standard, switch credentials, call a lower-level transport, or otherwise bypass the block without fresh explicit approval. Applying ordinary catalog metadata must not reactivate a blocked or deprecated event.
|
|
132
|
+
|
|
133
|
+
Before asking to reactivate an event, tell the developer why it was blocked, the faulty call path, the implemented fix, the previous and expected event rate per context and active user-day, the validation performed, and the remaining risk. Only an unambiguous instruction given after that disclosure authorizes the exact `blocked` to `active` transition. Use the dedicated CLI activation command with its required reason and exact event-name confirmation, then verify both the catalog state and newly observed event health. Do not edit the database or repeatedly reactivate an event that becomes blocked again without a new diagnosis and approval.
|
|
134
|
+
|
|
72
135
|
## Initialize At The Earliest Safe Lifecycle Point
|
|
73
136
|
|
|
74
137
|
Initialize GameAlgo at the beginning of the earliest runtime lifecycle hook where its minimal required dependencies are available. Do not place it after save loading, scene creation, asset loading, UI construction, ad initialization, or a broad game-module bootstrap merely for organizational convenience. Any unhandled failure before the call would otherwise exclude an opened user from telemetry, and an SDK initialization watchdog cannot report a failure when the SDK was never started.
|
|
@@ -100,6 +163,32 @@ When client code implements an experiment or remote Strategy, define a stable, t
|
|
|
100
163
|
|
|
101
164
|
Before delivery, inspect changed client code for Variant or assignment-identity access and verify none of it reaches business conditionals. Test active control and treatment configs, missing configuration, and the post-promotion shape where the Variant becomes `default` while the promoted config remains active; unchanged parameters must produce unchanged game behavior regardless of the Variant name.
|
|
102
165
|
|
|
166
|
+
## Preserve Experiment Platform Scope
|
|
167
|
+
|
|
168
|
+
A Strategy key is shared by the game and keeps a global fallback default, while every Run belongs to exactly one runtime platform: `ios`, `android`, or `rest` (`rest` includes TapTap Maker). A promoted Run updates only its own platform default. Other platform defaults remain unchanged, and a platform without an explicit default continues to use the global fallback.
|
|
169
|
+
|
|
170
|
+
- When a Run includes `variantId: default`, the Server snapshots the effective default for that Run's platform, falling back to the global Strategy default only when that platform has no promoted default.
|
|
171
|
+
- `experiment run promote` needs no platform flag because the Run records its platform.
|
|
172
|
+
- When rolling a platform back to a global historical version, pass `experiment strategy rollback ... --platform <ios|android|rest>` so the global version is copied into that platform default rather than replacing the global fallback.
|
|
173
|
+
- Treat `experiment strategy publish` and `experiment strategy default` as global-fallback operations; they do not overwrite existing promoted platform defaults.
|
|
174
|
+
|
|
175
|
+
## Require Explicit Approval Before Publishing Experiments
|
|
176
|
+
|
|
177
|
+
Treat publishing a live experiment as a two-step workflow. Before approval, limit work to read-only analysis, experiment ideas, and local drafts. Do not publish an experiment-specific Strategy or Strategy version, create or start a Run, allocate traffic, or otherwise mutate live experiment state.
|
|
178
|
+
|
|
179
|
+
Before asking for approval, tell the developer:
|
|
180
|
+
|
|
181
|
+
- the target game and environment, eligible audience, and exclusions;
|
|
182
|
+
- the hypothesis, primary metric, and decision criterion;
|
|
183
|
+
- the exact control and treatment differences, including parameter values or scripts;
|
|
184
|
+
- the traffic allocation and planned duration, sample size, or review checkpoint;
|
|
185
|
+
- the expected benefit and realistic product, revenue, retention, user-experience, and technical risks that apply; and
|
|
186
|
+
- the stop-loss conditions and rollback or cancellation plan.
|
|
187
|
+
|
|
188
|
+
Then explicitly ask whether to publish that exact experiment. Only an unambiguous confirmation given after this disclosure authorizes publication. Earlier broad instructions such as “优化一下”, “做个实验”, or “按你判断”, and authorization for code, report, or configuration work do not authorize publishing an experiment. If a material detail changes after approval—including the target game, audience, variants, parameters or scripts, traffic, duration, objective, decision criterion, or risk profile—disclose the change and obtain fresh approval before publishing.
|
|
189
|
+
|
|
190
|
+
After an authorized publication, retrieve and report the actual Strategy and Run IDs, status, scope, and rollback path. Do not silently create another experiment, Run, round, or rollout. A direct request to pause, cancel, or roll back an existing experiment is sufficient authority for that protective action and must not be blocked by this publication gate.
|
|
191
|
+
|
|
103
192
|
## Protect D0 Monetization In Maker Experiments
|
|
104
193
|
|
|
105
194
|
For TapTap Maker and other Maker games, do not autonomously design, create, or start an experiment that removes all ads from a user's first local day, delays the first ad until D1 or later, or otherwise moves the game's core D0 ad inventory to a later day. Many Maker games lose a large share of users before D1, so this is a direct D0 revenue-risk decision rather than an ordinary ad-timing adjustment. A broad request to optimize ads is not approval for this design.
|