gamealgo-cli 0.3.7 → 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 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) {
@@ -151,14 +152,21 @@ export class GameAlgoAdminClient {
151
152
  async getReportPack() {
152
153
  return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`);
153
154
  }
154
- async previewReportPack(content) {
155
- return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack/preview`, { content });
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
+ });
156
160
  }
157
161
  async putReportPack(content) {
158
162
  return await this.put(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-pack`, { content });
159
163
  }
160
- async reportManifest() {
161
- return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/manifest`);
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}`);
162
170
  }
163
171
  async queryReportDashboard(body, options = {}) {
164
172
  return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/query`, body, options);
@@ -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]");
@@ -66,7 +76,7 @@ export async function handleEvents(client, args, global) {
66
76
  return;
67
77
  }
68
78
  if (sub !== "count")
69
- throw new Error("usage: gamealgo events <list|get|apply|deprecate|block|activate|count>");
79
+ throw new Error("usage: gamealgo events <list|get|diff|apply|deprecate|block|activate|count>");
70
80
  const timeoutMs = reportTimeoutMs(flags);
71
81
  const startedAt = Date.now();
72
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,
@@ -1,7 +1,8 @@
1
1
  import { extname, join } from "node:path";
2
2
  import { parseFlags, parseSelectorFlags } from "../args.js";
3
- import { formatDuration, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
3
+ import { formatDuration, markCommandFailed, printResult, reportTimeoutMs, startProgress, withCliMeta } from "../output.js";
4
4
  import { reportDashboardCsv, reportDashboardCsvExports } from "../report-csv.js";
5
+ import { normalizeReportSampleRows, summarizeReportDashboard, summarizeReportManifest, summarizeReportValidation, } from "../report-summary.js";
5
6
  import { objectValue, readJsonFile, writeTextFile } from "../resources.js";
6
7
  import { announceTargetGame } from "../target.js";
7
8
  import { optionalString } from "../utils.js";
@@ -18,9 +19,15 @@ export async function handleReport(client, args, global) {
18
19
  if (sub === "validate") {
19
20
  const filePath = args.shift();
20
21
  if (!filePath)
21
- 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);
22
25
  const content = await readJsonFile(filePath);
23
- await printResult(await client.previewReportPack(content), global);
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();
24
31
  return;
25
32
  }
26
33
  if (sub === "publish") {
@@ -35,8 +42,10 @@ export async function handleReport(client, args, global) {
35
42
  return;
36
43
  }
37
44
  if (sub === "manifest" || sub === "list") {
38
- parseFlags(args);
39
- await printResult(await client.reportManifest(), global);
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);
40
49
  return;
41
50
  }
42
51
  if (sub === "result") {
@@ -45,8 +54,12 @@ export async function handleReport(client, args, global) {
45
54
  const startDate = String(flags.from || flags.start || flags.startDate || "");
46
55
  const endDate = String(flags.to || flags.end || flags.endDate || "");
47
56
  if (!startDate || !endDate)
48
- 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] [--format json|csv] [--out file-or-directory]");
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");
49
59
  const timeoutMs = reportTimeoutMs(flags);
60
+ const format = reportOutputFormat(flags.format);
61
+ const full = reportFullOutput(flags) || format === "csv";
62
+ const sampleRows = reportSampleRows(flags, full);
50
63
  const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
51
64
  const startedAt = Date.now();
52
65
  const stopProgress = startProgress("Querying report results", timeoutMs);
@@ -64,15 +77,19 @@ export async function handleReport(client, args, global) {
64
77
  platform,
65
78
  selectors,
66
79
  refresh: Boolean(flags.refresh),
80
+ responseMode: full ? "full" : "summary",
81
+ sampleRows,
67
82
  }, { timeoutMs });
68
83
  }
69
84
  finally {
70
85
  stopProgress();
71
86
  }
72
87
  const elapsedMs = Date.now() - startedAt;
73
- const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
88
+ const rawOutputValue = withCliMeta(response, { elapsedMs, timeoutMs });
89
+ const statusSummary = summarizeReportDashboard(rawOutputValue, { command: "report.result", sampleRows });
90
+ const outputValue = full ? rawOutputValue : statusSummary;
74
91
  process.stderr.write(`Report query finished in ${formatDuration(elapsedMs)}.\n`);
75
- await writeReportQueryOutput(outputValue, flags, global, elapsedMs);
92
+ await writeReportQueryOutput(outputValue, flags, global, elapsedMs, statusSummary.ok);
76
93
  return;
77
94
  }
78
95
  if (sub === "preview") {
@@ -82,10 +99,14 @@ export async function handleReport(client, args, global) {
82
99
  const startDate = String(flags.from || flags.start || flags.startDate || "");
83
100
  const endDate = String(flags.to || flags.end || flags.endDate || "");
84
101
  if (!filePath || !startDate || !endDate) {
85
- 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] [--format json|csv] [--out file-or-directory]");
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]");
86
103
  }
104
+ requireReportQueryScope(flags, "report preview");
87
105
  const content = await readJsonFile(filePath);
88
106
  const timeoutMs = reportTimeoutMs(flags);
107
+ const format = reportOutputFormat(flags.format);
108
+ const full = reportFullOutput(flags) || format === "csv";
109
+ const sampleRows = reportSampleRows(flags, full);
89
110
  const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
90
111
  const startedAt = Date.now();
91
112
  const stopProgress = startProgress("Previewing report results", timeoutMs);
@@ -103,19 +124,43 @@ export async function handleReport(client, args, global) {
103
124
  chartId: optionalString(flags["chart-id"]),
104
125
  platform,
105
126
  selectors,
127
+ responseMode: full ? "full" : "summary",
128
+ sampleRows,
106
129
  }, { timeoutMs });
107
130
  }
108
131
  finally {
109
132
  stopProgress();
110
133
  }
111
134
  const elapsedMs = Date.now() - startedAt;
112
- const outputValue = withCliMeta(response, { elapsedMs, timeoutMs });
135
+ const rawOutputValue = withCliMeta(response, { elapsedMs, timeoutMs });
136
+ const statusSummary = summarizeReportDashboard(rawOutputValue, { command: "report.preview", sampleRows });
137
+ const outputValue = full ? rawOutputValue : statusSummary;
113
138
  process.stderr.write(`Report preview finished in ${formatDuration(elapsedMs)}.\n`);
114
- await writeReportQueryOutput(outputValue, flags, global, elapsedMs);
139
+ await writeReportQueryOutput(outputValue, flags, global, elapsedMs, statusSummary.ok);
115
140
  return;
116
141
  }
117
142
  throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview>");
118
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
+ }
119
164
  export function reportOutputFormat(value) {
120
165
  if (value === undefined || value === null || value === "")
121
166
  return "json";
@@ -126,7 +171,7 @@ export function reportOutputFormat(value) {
126
171
  return normalized;
127
172
  throw new Error("--format must be json or csv");
128
173
  }
129
- async function writeReportQueryOutput(outputValue, flags, global, elapsedMs) {
174
+ async function writeReportQueryOutput(outputValue, flags, global, elapsedMs, succeeded) {
130
175
  const format = reportOutputFormat(flags.format);
131
176
  const out = optionalString(flags.out);
132
177
  if (flags.out === true)
@@ -143,7 +188,7 @@ async function writeReportQueryOutput(outputValue, flags, global, elapsedMs) {
143
188
  }
144
189
  const first = files[0];
145
190
  const summary = files.length === 1 ? {
146
- ok: true,
191
+ ok: succeeded,
147
192
  out: first.out,
148
193
  format,
149
194
  reportId: first.exported.reportId,
@@ -154,7 +199,7 @@ async function writeReportQueryOutput(outputValue, flags, global, elapsedMs) {
154
199
  truncated: first.exported.truncated,
155
200
  elapsedMs,
156
201
  } : {
157
- ok: true,
202
+ ok: succeeded,
158
203
  out,
159
204
  format,
160
205
  results: files.length,
@@ -175,19 +220,28 @@ async function writeReportQueryOutput(outputValue, flags, global, elapsedMs) {
175
220
  const exported = reportDashboardCsv(outputValue);
176
221
  warnTruncatedCsv(exported);
177
222
  process.stdout.write(exported.content);
223
+ if (!succeeded)
224
+ markCommandFailed();
178
225
  return;
179
226
  }
180
227
  if (out) {
181
228
  await writeTextFile(out, JSON.stringify(outputValue, null, 2) + "\n");
229
+ const outputObject = outputValue && typeof outputValue === "object" && !Array.isArray(outputValue)
230
+ ? outputValue
231
+ : {};
182
232
  await printResult({
183
- ok: true,
233
+ ok: succeeded,
184
234
  out,
185
- results: Array.isArray(outputValue.results) ? outputValue.results.length : 0,
235
+ results: Array.isArray(outputObject.results)
236
+ ? outputObject.results.length
237
+ : Array.isArray(outputObject.items) ? outputObject.items.length : 0,
186
238
  elapsedMs,
187
239
  }, global);
188
240
  return;
189
241
  }
190
242
  await printResult(outputValue, global);
243
+ if (!succeeded)
244
+ markCommandFailed();
191
245
  }
192
246
  function reportCsvOutputFiles(exports, out) {
193
247
  if (exports.length === 1)
@@ -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
- throw new Error(formatHttpError(response.status, response.statusText, text, json));
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
@@ -13,6 +13,7 @@ import { handleSkill } from "./commands/skill.js";
13
13
  import { loadPackageMetadata } from "./config.js";
14
14
  import { handleDocs, handleHelp } from "./docs.js";
15
15
  import { handleDynamicConfig } from "./commands/dynamic-config.js";
16
+ import { cliErrorPayload } from "./http.js";
16
17
  import { printResult } from "./output.js";
17
18
  export async function main() {
18
19
  const args = process.argv.slice(2);
@@ -110,7 +111,7 @@ export async function main() {
110
111
  catch (error) {
111
112
  const message = error instanceof Error ? error.message : String(error);
112
113
  if (global.json) {
113
- console.log(JSON.stringify({ ok: false, error: message }, null, 2));
114
+ console.log(JSON.stringify(cliErrorPayload(error), null, 2));
114
115
  }
115
116
  else {
116
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,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamealgo-cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "GameAlgo command-line client for AI agents and game developers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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, or when discovering supported GameAlgo iteration tasks for a game.
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
@@ -42,6 +42,12 @@ Use each topic's section list as the router. Read the whole topic only when the
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
@@ -81,6 +87,18 @@ Reuse events with the same business meaning, register new fields with their real
81
87
 
82
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.
83
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
+
84
102
  ## Bound Custom Event Volume
85
103
 
86
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.
@@ -145,6 +163,15 @@ When client code implements an experiment or remote Strategy, define a stable, t
145
163
 
146
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.
147
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
+
148
175
  ## Require Explicit Approval Before Publishing Experiments
149
176
 
150
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.