@playdrop/playdrop-cli 0.10.0 → 0.10.2
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/config/client-meta.json +2 -1
- package/dist/apiClient.d.ts +8 -0
- package/dist/apiClient.js +29 -1
- package/dist/captureRuntime.d.ts +13 -0
- package/dist/captureRuntime.js +21 -0
- package/dist/commandContext.js +21 -3
- package/dist/commands/captureRemote.d.ts +2 -0
- package/dist/commands/captureRemote.js +90 -0
- package/dist/commands/review.d.ts +46 -0
- package/dist/commands/review.js +353 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/worker/runtime.d.ts +12 -0
- package/dist/commands/worker/runtime.js +79 -35
- package/dist/commands/worker.d.ts +17 -3
- package/dist/commands/worker.js +431 -24
- package/dist/index.js +45 -0
- package/dist/workspaceAuth.d.ts +2 -0
- package/dist/workspaceAuth.js +6 -0
- package/node_modules/@playdrop/api-client/dist/client.d.ts +6 -2
- package/node_modules/@playdrop/api-client/dist/client.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts +3 -2
- package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/domains/admin.js +6 -3
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts +4 -1
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.js +36 -0
- package/node_modules/@playdrop/api-client/dist/index.d.ts +6 -2
- package/node_modules/@playdrop/api-client/dist/index.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/index.js +21 -6
- package/node_modules/@playdrop/config/client-meta.json +2 -1
- package/node_modules/@playdrop/types/dist/api.d.ts +163 -3
- package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/api.js +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.REQUIRED_REVIEW_EVIDENCE_FILES = exports.REVIEW_CRITERIA = void 0;
|
|
7
|
+
exports.validateGameReviewResult = validateGameReviewResult;
|
|
8
|
+
exports.validateReviewResultCommand = validateReviewResultCommand;
|
|
9
|
+
exports.composeReviewEvidence = composeReviewEvidence;
|
|
10
|
+
exports.createReviewRatingCard = createReviewRatingCard;
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
14
|
+
const output_1 = require("../output");
|
|
15
|
+
exports.REVIEW_CRITERIA = [
|
|
16
|
+
'Gameplay / Core Loop',
|
|
17
|
+
'Depth / Replayability',
|
|
18
|
+
'Controls / Input',
|
|
19
|
+
'UX / Usability',
|
|
20
|
+
'First Time User Experience',
|
|
21
|
+
'Visuals / Art Direction',
|
|
22
|
+
'Audio / Feedback',
|
|
23
|
+
'Store Listing & Metadata Accuracy',
|
|
24
|
+
'Safety / Age Rating / Compliance',
|
|
25
|
+
'Performance / Stability',
|
|
26
|
+
];
|
|
27
|
+
const TERMINAL_REVIEW_STATES = new Set([
|
|
28
|
+
'FAILED',
|
|
29
|
+
'LOW_QUALITY',
|
|
30
|
+
'PASSED',
|
|
31
|
+
'GOOD',
|
|
32
|
+
'EXCELLENT',
|
|
33
|
+
]);
|
|
34
|
+
const STATE_OUTCOME = {
|
|
35
|
+
EXCELLENT: 'Excellent',
|
|
36
|
+
FAILED: 'Blocked',
|
|
37
|
+
GOOD: 'Good',
|
|
38
|
+
LOW_QUALITY: 'Limited',
|
|
39
|
+
PASSED: 'Passed',
|
|
40
|
+
};
|
|
41
|
+
exports.REQUIRED_REVIEW_EVIDENCE_FILES = [
|
|
42
|
+
'core.png',
|
|
43
|
+
'win.png',
|
|
44
|
+
'loss.png',
|
|
45
|
+
'composite.png',
|
|
46
|
+
'rating-card.png',
|
|
47
|
+
];
|
|
48
|
+
const REVIEW_EVIDENCE_MAX_BYTES = 10 * 1024 * 1024;
|
|
49
|
+
function resolveFilePath(filePath, code) {
|
|
50
|
+
const normalized = typeof filePath === 'string' ? filePath.trim() : '';
|
|
51
|
+
if (!normalized) {
|
|
52
|
+
throw new Error(code);
|
|
53
|
+
}
|
|
54
|
+
return node_path_1.default.isAbsolute(normalized) ? normalized : node_path_1.default.resolve(process.cwd(), normalized);
|
|
55
|
+
}
|
|
56
|
+
async function readRequiredTextFile(filePath, code) {
|
|
57
|
+
const resolved = resolveFilePath(filePath, code);
|
|
58
|
+
const content = await node_fs_1.promises.readFile(resolved, 'utf8');
|
|
59
|
+
if (!content.trim()) {
|
|
60
|
+
throw new Error(code);
|
|
61
|
+
}
|
|
62
|
+
return content;
|
|
63
|
+
}
|
|
64
|
+
function requireLine(text, prefix) {
|
|
65
|
+
const line = text.split(/\r?\n/).find((entry) => entry.startsWith(prefix));
|
|
66
|
+
if (!line) {
|
|
67
|
+
throw new Error(`missing_review_line:${prefix}`);
|
|
68
|
+
}
|
|
69
|
+
return line.slice(prefix.length).trim();
|
|
70
|
+
}
|
|
71
|
+
function normalizeReviewText(...values) {
|
|
72
|
+
return values
|
|
73
|
+
.join(' ')
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/\s+/g, ' ')
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
function parseScores(reviewMessage) {
|
|
79
|
+
if (/\/5\b/.test(reviewMessage)) {
|
|
80
|
+
throw new Error('review_scores_must_use_10_point_scale');
|
|
81
|
+
}
|
|
82
|
+
const scores = {};
|
|
83
|
+
for (const criterion of exports.REVIEW_CRITERIA) {
|
|
84
|
+
const escaped = criterion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
85
|
+
const regex = new RegExp(`^- ${escaped}: (10|[1-9])/10 \\| .+`, 'm');
|
|
86
|
+
const match = reviewMessage.match(regex);
|
|
87
|
+
if (!match) {
|
|
88
|
+
throw new Error(`missing_or_invalid_criterion_score:${criterion}`);
|
|
89
|
+
}
|
|
90
|
+
scores[criterion] = Number.parseInt(match[1], 10);
|
|
91
|
+
}
|
|
92
|
+
return scores;
|
|
93
|
+
}
|
|
94
|
+
function averageScore(scores) {
|
|
95
|
+
const values = Object.values(scores);
|
|
96
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
97
|
+
}
|
|
98
|
+
function minimumScore(scores) {
|
|
99
|
+
return Math.min(...Object.values(scores));
|
|
100
|
+
}
|
|
101
|
+
function validateOutcomeAgainstScores(outcome, scores) {
|
|
102
|
+
const average = averageScore(scores);
|
|
103
|
+
const minimum = minimumScore(scores);
|
|
104
|
+
const gameplay = scores['Gameplay / Core Loop'];
|
|
105
|
+
const controls = scores['Controls / Input'];
|
|
106
|
+
const ux = scores['UX / Usability'];
|
|
107
|
+
const ftue = scores['First Time User Experience'];
|
|
108
|
+
const visuals = scores['Visuals / Art Direction'];
|
|
109
|
+
const performance = scores['Performance / Stability'];
|
|
110
|
+
const actual = `actual=average=${average.toFixed(2)},minimum=${minimum},gameplay=${gameplay},controls=${controls},ux=${ux},ftue=${ftue},visuals=${visuals},performance=${performance}`;
|
|
111
|
+
if (outcome === 'Excellent' && (average < 8.5 || minimum < 7 || gameplay < 8 || controls < 8 || ux < 8 || ftue < 7 || visuals < 8 || performance < 8)) {
|
|
112
|
+
throw new Error(`excellent_outcome_not_supported_by_scores:requires=average>=8.5,minimum>=7,gameplay>=8,controls>=8,ux>=8,ftue>=7,visuals>=8,performance>=8:${actual}:use_good_or_lower_outcome_unless_scores_are_supported_by_evidence`);
|
|
113
|
+
}
|
|
114
|
+
if (outcome === 'Good' && (average < 7.5 || minimum < 6 || gameplay < 7 || controls < 7 || ux < 7 || ftue < 6 || visuals < 7 || performance < 7)) {
|
|
115
|
+
throw new Error(`good_outcome_not_supported_by_scores:requires=average>=7.5,minimum>=6,gameplay>=7,controls>=7,ux>=7,ftue>=6,visuals>=7,performance>=7:${actual}:use_passed_or_lower_outcome_unless_scores_are_supported_by_evidence`);
|
|
116
|
+
}
|
|
117
|
+
if (outcome === 'Passed' && (average < 6 || minimum < 4 || gameplay < 5 || controls < 5 || performance < 5)) {
|
|
118
|
+
throw new Error(`passed_outcome_not_supported_by_scores:requires=average>=6,minimum>=4,gameplay>=5,controls>=5,performance>=5:${actual}:use_low_quality_or_failed_unless_scores_are_supported_by_evidence`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function validateRequiredEvidenceLines(reviewMessage) {
|
|
122
|
+
const primaryVerb = requireLine(reviewMessage, 'Primary player verb: ');
|
|
123
|
+
const primaryEvidence = requireLine(reviewMessage, 'Primary interaction evidence: ');
|
|
124
|
+
const challengeEvidence = requireLine(reviewMessage, 'Challenge evidence: ');
|
|
125
|
+
const punchline = requireLine(reviewMessage, 'Punchline assessment: ');
|
|
126
|
+
const benchmark = requireLine(reviewMessage, 'Comparable benchmark: ');
|
|
127
|
+
if (primaryVerb.length < 3) {
|
|
128
|
+
throw new Error('primary_player_verb_too_vague');
|
|
129
|
+
}
|
|
130
|
+
if (primaryEvidence.length < 20) {
|
|
131
|
+
throw new Error('primary_interaction_evidence_too_vague');
|
|
132
|
+
}
|
|
133
|
+
if (challengeEvidence.length < 20) {
|
|
134
|
+
throw new Error('challenge_evidence_too_vague');
|
|
135
|
+
}
|
|
136
|
+
if (punchline.length < 8 || punchline.length > 160) {
|
|
137
|
+
throw new Error('punchline_assessment_invalid_length');
|
|
138
|
+
}
|
|
139
|
+
if (benchmark.length < 8) {
|
|
140
|
+
throw new Error('comparable_benchmark_too_vague');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isNoScoreCaps(scoreCapsApplied) {
|
|
144
|
+
return scoreCapsApplied.trim().toLowerCase() === 'none';
|
|
145
|
+
}
|
|
146
|
+
function validateCapConsistency(input) {
|
|
147
|
+
const normalizedText = normalizeReviewText(input.reviewMessage, input.creatorFeedback);
|
|
148
|
+
const describesZeroChallenge = /\bzero challenge\b|\bno (?:real |meaningful )?challenge\b(?!-)|\bchallenge (?:is )?(?:absent|missing|nonexistent)\b/.test(normalizedText);
|
|
149
|
+
const describesPrimaryMissing = /\bprimary (?:interaction|verb) (?:is )?(?:absent|missing|unavailable)\b/.test(normalizedText);
|
|
150
|
+
if (isNoScoreCaps(input.scoreCapsApplied) && (describesZeroChallenge || describesPrimaryMissing)) {
|
|
151
|
+
const trigger = describesZeroChallenge ? 'zero_or_missing_challenge' : 'primary_interaction_missing';
|
|
152
|
+
throw new Error(`missing_required_score_cap:${trigger}:score_caps_applied_must_name_the_triggered_cap_or_adjust_the_evidence_wording`);
|
|
153
|
+
}
|
|
154
|
+
if (describesZeroChallenge && (['Passed', 'Good', 'Excellent'].includes(input.outcome) || input.scores['Gameplay / Core Loop'] > 3 || input.scores['Depth / Replayability'] > 3)) {
|
|
155
|
+
throw new Error('gameplay_challenge_absent_score_cap_required');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function assertPngFile(filePath) {
|
|
159
|
+
const bytes = await node_fs_1.promises.readFile(filePath);
|
|
160
|
+
if (bytes.length <= 0 || bytes.length > REVIEW_EVIDENCE_MAX_BYTES) {
|
|
161
|
+
throw new Error(`invalid_review_evidence_size:${filePath}`);
|
|
162
|
+
}
|
|
163
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
164
|
+
for (let index = 0; index < signature.length; index += 1) {
|
|
165
|
+
if (bytes[index] !== signature[index]) {
|
|
166
|
+
throw new Error(`review_evidence_not_png:${filePath}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function validateGameReviewResult(input) {
|
|
171
|
+
const normalizedState = String(input.reviewState || '').trim().toUpperCase();
|
|
172
|
+
if (!TERMINAL_REVIEW_STATES.has(normalizedState)) {
|
|
173
|
+
return { skipped: true, reason: 'non_terminal_state' };
|
|
174
|
+
}
|
|
175
|
+
if (!input.reviewMessage.trim()) {
|
|
176
|
+
throw new Error('missing_review_message');
|
|
177
|
+
}
|
|
178
|
+
const creatorFeedback = input.creatorFeedback ?? '';
|
|
179
|
+
if (!creatorFeedback.trim()) {
|
|
180
|
+
throw new Error('missing_creator_feedback');
|
|
181
|
+
}
|
|
182
|
+
if (!input.evidenceDir.trim()) {
|
|
183
|
+
throw new Error('missing_evidence_dir');
|
|
184
|
+
}
|
|
185
|
+
const expectedOutcome = STATE_OUTCOME[normalizedState];
|
|
186
|
+
const actualOutcome = requireLine(input.reviewMessage, 'Outcome: ');
|
|
187
|
+
if (actualOutcome !== expectedOutcome) {
|
|
188
|
+
throw new Error(`review_outcome_state_mismatch:${actualOutcome}:${normalizedState}`);
|
|
189
|
+
}
|
|
190
|
+
const primarySurface = requireLine(input.reviewMessage, 'Primary reviewed surface: ');
|
|
191
|
+
if (!['MOBILE_PORTRAIT', 'MOBILE_LANDSCAPE', 'DESKTOP'].includes(primarySurface)) {
|
|
192
|
+
throw new Error(`invalid_primary_reviewed_surface:${primarySurface}`);
|
|
193
|
+
}
|
|
194
|
+
validateRequiredEvidenceLines(input.reviewMessage);
|
|
195
|
+
const scoreCapsApplied = requireLine(input.reviewMessage, 'Score caps applied: ');
|
|
196
|
+
if (!creatorFeedback.includes(`Reviewed surface: ${primarySurface}`)) {
|
|
197
|
+
throw new Error('creator_feedback_missing_reviewed_surface');
|
|
198
|
+
}
|
|
199
|
+
const scores = parseScores(input.reviewMessage);
|
|
200
|
+
validateOutcomeAgainstScores(actualOutcome, scores);
|
|
201
|
+
validateCapConsistency({
|
|
202
|
+
creatorFeedback,
|
|
203
|
+
outcome: actualOutcome,
|
|
204
|
+
reviewMessage: input.reviewMessage,
|
|
205
|
+
scoreCapsApplied,
|
|
206
|
+
scores,
|
|
207
|
+
});
|
|
208
|
+
const evidenceDir = resolveFilePath(input.evidenceDir, 'missing_evidence_dir');
|
|
209
|
+
for (const filename of exports.REQUIRED_REVIEW_EVIDENCE_FILES) {
|
|
210
|
+
await assertPngFile(node_path_1.default.join(evidenceDir, filename));
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
average: averageScore(scores),
|
|
214
|
+
evidenceDir,
|
|
215
|
+
outcome: actualOutcome,
|
|
216
|
+
primarySurface,
|
|
217
|
+
scoreCount: Object.keys(scores).length,
|
|
218
|
+
skipped: false,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function validateReviewResultCommand(options) {
|
|
222
|
+
const result = await validateGameReviewResult({
|
|
223
|
+
creatorFeedback: options.creatorFeedbackFile ? await readRequiredTextFile(options.creatorFeedbackFile, 'invalid_creator_feedback_file') : '',
|
|
224
|
+
evidenceDir: options.evidenceDir,
|
|
225
|
+
reviewMessage: await readRequiredTextFile(options.messageFile, 'invalid_review_message_file'),
|
|
226
|
+
reviewState: options.state,
|
|
227
|
+
});
|
|
228
|
+
console.log(JSON.stringify(result, null, 2));
|
|
229
|
+
}
|
|
230
|
+
async function resizeForComposite(filePath) {
|
|
231
|
+
const image = (0, sharp_1.default)(resolveFilePath(filePath, 'invalid_review_evidence_file'));
|
|
232
|
+
const metadata = await image.metadata();
|
|
233
|
+
if (!metadata.width || !metadata.height) {
|
|
234
|
+
throw new Error('invalid_review_evidence_image');
|
|
235
|
+
}
|
|
236
|
+
const buffer = await image
|
|
237
|
+
.resize({ width: 540, height: 960, fit: 'inside', withoutEnlargement: true })
|
|
238
|
+
.png()
|
|
239
|
+
.toBuffer();
|
|
240
|
+
const resizedMetadata = await (0, sharp_1.default)(buffer).metadata();
|
|
241
|
+
if (!resizedMetadata.width || !resizedMetadata.height) {
|
|
242
|
+
throw new Error('invalid_review_evidence_image');
|
|
243
|
+
}
|
|
244
|
+
return { buffer, width: resizedMetadata.width, height: resizedMetadata.height };
|
|
245
|
+
}
|
|
246
|
+
async function composeReviewEvidence(options) {
|
|
247
|
+
const entries = [
|
|
248
|
+
options.core ? { label: 'core', path: options.core } : null,
|
|
249
|
+
options.win ? { label: 'win', path: options.win } : null,
|
|
250
|
+
options.loss ? { label: 'loss', path: options.loss } : null,
|
|
251
|
+
].filter((entry) => Boolean(entry));
|
|
252
|
+
if (entries.length === 0) {
|
|
253
|
+
throw new Error('missing_review_evidence_images');
|
|
254
|
+
}
|
|
255
|
+
const images = await Promise.all(entries.map((entry) => resizeForComposite(entry.path)));
|
|
256
|
+
const padding = 16;
|
|
257
|
+
const width = images.reduce((sum, image) => sum + image.width, padding * (images.length + 1));
|
|
258
|
+
const height = Math.max(...images.map((image) => image.height)) + padding * 2;
|
|
259
|
+
let left = padding;
|
|
260
|
+
const composites = images.map((image) => {
|
|
261
|
+
const composite = { input: image.buffer, left, top: padding };
|
|
262
|
+
left += image.width + padding;
|
|
263
|
+
return composite;
|
|
264
|
+
});
|
|
265
|
+
const outputPath = resolveFilePath(options.out, 'missing_out');
|
|
266
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
|
|
267
|
+
await (0, sharp_1.default)({
|
|
268
|
+
create: {
|
|
269
|
+
width,
|
|
270
|
+
height,
|
|
271
|
+
channels: 4,
|
|
272
|
+
background: { r: 12, g: 12, b: 16, alpha: 1 },
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
.composite(composites)
|
|
276
|
+
.png()
|
|
277
|
+
.toFile(outputPath);
|
|
278
|
+
(0, output_1.printSuccess)(`Review evidence composite written to ${outputPath}`);
|
|
279
|
+
return { outputPath, imageCount: images.length };
|
|
280
|
+
}
|
|
281
|
+
function escapeXml(value) {
|
|
282
|
+
return value
|
|
283
|
+
.replace(/&/g, '&')
|
|
284
|
+
.replace(/</g, '<')
|
|
285
|
+
.replace(/>/g, '>')
|
|
286
|
+
.replace(/"/g, '"');
|
|
287
|
+
}
|
|
288
|
+
function parseCriterionRating(reviewMessage, criterion) {
|
|
289
|
+
const escaped = criterion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
290
|
+
const regex = new RegExp(`^- ${escaped}: (10|[1-9])/10 \\| .+`, 'm');
|
|
291
|
+
const match = reviewMessage.match(regex);
|
|
292
|
+
if (!match) {
|
|
293
|
+
throw new Error(`missing_or_invalid_criterion_score:${criterion}`);
|
|
294
|
+
}
|
|
295
|
+
return Number.parseInt(match[1], 10);
|
|
296
|
+
}
|
|
297
|
+
function ratingColor(score) {
|
|
298
|
+
if (score <= 1)
|
|
299
|
+
return '#ff3b30';
|
|
300
|
+
if (score <= 4)
|
|
301
|
+
return '#ff9500';
|
|
302
|
+
if (score <= 6)
|
|
303
|
+
return '#ffd60a';
|
|
304
|
+
if (score <= 9)
|
|
305
|
+
return '#34c759';
|
|
306
|
+
return '#bf5af2';
|
|
307
|
+
}
|
|
308
|
+
function outcomeColor(outcome) {
|
|
309
|
+
if (outcome === 'Blocked')
|
|
310
|
+
return '#ff3b30';
|
|
311
|
+
if (outcome === 'Limited')
|
|
312
|
+
return '#ff9500';
|
|
313
|
+
if (outcome === 'Passed')
|
|
314
|
+
return '#ffd60a';
|
|
315
|
+
if (outcome === 'Good')
|
|
316
|
+
return '#34c759';
|
|
317
|
+
if (outcome === 'Excellent')
|
|
318
|
+
return '#bf5af2';
|
|
319
|
+
return '#ff9500';
|
|
320
|
+
}
|
|
321
|
+
async function createReviewRatingCard(options) {
|
|
322
|
+
const reviewMessage = await readRequiredTextFile(options.reviewMessageFile, 'invalid_review_message_file');
|
|
323
|
+
const outcome = requireLine(reviewMessage, 'Outcome: ');
|
|
324
|
+
const punchline = (options.punchline?.trim() || requireLine(reviewMessage, 'Punchline assessment: ')).slice(0, 160);
|
|
325
|
+
const title = (options.title?.trim() || 'Game review ratings').slice(0, 80);
|
|
326
|
+
const rows = exports.REVIEW_CRITERIA.map((criterion, index) => {
|
|
327
|
+
const score = parseCriterionRating(reviewMessage, criterion);
|
|
328
|
+
const x = index < 5 ? 56 : 628;
|
|
329
|
+
const y = 170 + (index % 5) * 92;
|
|
330
|
+
return `
|
|
331
|
+
<rect x="${x}" y="${y}" width="516" height="70" rx="6" fill="#15151c" stroke="#333340"/>
|
|
332
|
+
<rect x="${x}" y="${y}" width="8" height="70" fill="${ratingColor(score)}"/>
|
|
333
|
+
<text x="${x + 24}" y="${y + 42}" fill="#f5f5f7" font-size="22" font-weight="700">${escapeXml(criterion)}</text>
|
|
334
|
+
<text x="${x + 448}" y="${y + 44}" fill="${ratingColor(score)}" font-size="32" font-weight="800" text-anchor="end">${score}/10</text>
|
|
335
|
+
`;
|
|
336
|
+
}).join('');
|
|
337
|
+
const svg = `
|
|
338
|
+
<svg width="1200" height="760" xmlns="http://www.w3.org/2000/svg">
|
|
339
|
+
<rect width="1200" height="760" fill="#000"/>
|
|
340
|
+
<text x="56" y="82" fill="#fff" font-size="38" font-weight="800">${escapeXml(title)}</text>
|
|
341
|
+
<text x="1144" y="82" fill="${outcomeColor(outcome)}" font-size="30" font-weight="800" text-anchor="end">${escapeXml(outcome)}</text>
|
|
342
|
+
<line x1="56" y1="122" x2="1144" y2="122" stroke="#333340"/>
|
|
343
|
+
${rows}
|
|
344
|
+
<line x1="56" y1="650" x2="1144" y2="650" stroke="#333340"/>
|
|
345
|
+
<text x="600" y="708" fill="${outcomeColor(outcome)}" font-size="32" font-weight="800" text-anchor="middle">${escapeXml(punchline)}</text>
|
|
346
|
+
</svg>
|
|
347
|
+
`;
|
|
348
|
+
const outputPath = resolveFilePath(options.out, 'missing_out');
|
|
349
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
|
|
350
|
+
await (0, sharp_1.default)(Buffer.from(svg)).png().toFile(outputPath);
|
|
351
|
+
(0, output_1.printSuccess)(`Review rating card written to ${outputPath}`);
|
|
352
|
+
return { outputPath, width: 1200, height: 760 };
|
|
353
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validate = validate;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
4
6
|
const types_1 = require("@playdrop/types");
|
|
5
7
|
const types_2 = require("@playdrop/types");
|
|
6
8
|
const commandContext_1 = require("../commandContext");
|
|
@@ -12,6 +14,66 @@ const assetSpecs_1 = require("../assetSpecs");
|
|
|
12
14
|
const externalAssetPackValidation_1 = require("../externalAssetPackValidation");
|
|
13
15
|
const dev_1 = require("./dev");
|
|
14
16
|
const upload_content_1 = require("./upload-content");
|
|
17
|
+
function normalizePositivePort(value) {
|
|
18
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
19
|
+
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null;
|
|
20
|
+
}
|
|
21
|
+
function findWorkspaceMetadataFile(startPath) {
|
|
22
|
+
let current = (0, node_path_1.resolve)(startPath);
|
|
23
|
+
if ((0, node_fs_1.existsSync)(current) && (0, node_fs_1.statSync)(current).isFile()) {
|
|
24
|
+
current = (0, node_path_1.dirname)(current);
|
|
25
|
+
}
|
|
26
|
+
while (true) {
|
|
27
|
+
const workspacePinPath = (0, node_path_1.join)(current, '.playdrop.json');
|
|
28
|
+
if ((0, node_fs_1.existsSync)(workspacePinPath) && (0, node_fs_1.statSync)(workspacePinPath).isFile()) {
|
|
29
|
+
return workspacePinPath;
|
|
30
|
+
}
|
|
31
|
+
const taskContextPath = (0, node_path_1.join)(current, '.playdrop', 'task.json');
|
|
32
|
+
if ((0, node_fs_1.existsSync)(taskContextPath) && (0, node_fs_1.statSync)(taskContextPath).isFile()) {
|
|
33
|
+
return taskContextPath;
|
|
34
|
+
}
|
|
35
|
+
const parent = (0, node_path_1.dirname)(current);
|
|
36
|
+
if (parent === current) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function readTaskScopedDevRouterPort(workspacePath) {
|
|
43
|
+
const metadataPath = findWorkspaceMetadataFile(workspacePath);
|
|
44
|
+
if (!metadataPath) {
|
|
45
|
+
throw new Error('project_validate_task_dev_port_missing');
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse((0, node_fs_1.readFileSync)(metadataPath, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`project_validate_task_dev_port_invalid_json:${metadataPath}`);
|
|
53
|
+
}
|
|
54
|
+
const port = normalizePositivePort(parsed?.devPort);
|
|
55
|
+
if (!port) {
|
|
56
|
+
throw new Error(`project_validate_task_dev_port_missing:${metadataPath}`);
|
|
57
|
+
}
|
|
58
|
+
return { file: metadataPath, port };
|
|
59
|
+
}
|
|
60
|
+
function resolveValidationDevRouterPort(workspacePath, taskScoped) {
|
|
61
|
+
if (!taskScoped) {
|
|
62
|
+
return (0, dev_1.resolveDevRouterPort)(process.env.PLAYDROP_DEV_ROUTER_PORT);
|
|
63
|
+
}
|
|
64
|
+
const taskPort = readTaskScopedDevRouterPort(workspacePath);
|
|
65
|
+
const envPortRaw = process.env.PLAYDROP_DEV_ROUTER_PORT;
|
|
66
|
+
if (envPortRaw !== undefined && envPortRaw.trim()) {
|
|
67
|
+
const envPort = normalizePositivePort(envPortRaw);
|
|
68
|
+
if (!envPort) {
|
|
69
|
+
throw new Error(`project_validate_env_dev_port_invalid:${envPortRaw}`);
|
|
70
|
+
}
|
|
71
|
+
if (envPort !== taskPort.port) {
|
|
72
|
+
throw new Error(`project_validate_worker_dev_port_mismatch:env=${envPort}:task=${taskPort.port}:${taskPort.file}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return taskPort.port;
|
|
76
|
+
}
|
|
15
77
|
function buildLocalAssetSpecLookups(tasks) {
|
|
16
78
|
const exactByRef = new Map();
|
|
17
79
|
const uniqueByNameVersion = new Map();
|
|
@@ -115,7 +177,7 @@ async function loadHostedLaunchValidationContext(workspacePath, envOverride) {
|
|
|
115
177
|
apiBase: ctx.envConfig.apiBase,
|
|
116
178
|
webBase: ctx.envConfig.webBase ?? null,
|
|
117
179
|
token: ctx.token,
|
|
118
|
-
devRouterPort: (
|
|
180
|
+
devRouterPort: resolveValidationDevRouterPort(workspacePath, Boolean(ctx.workspaceAuth?.config.taskToken)),
|
|
119
181
|
taskScoped: Boolean(ctx.workspaceAuth?.config.taskToken),
|
|
120
182
|
};
|
|
121
183
|
}
|
|
@@ -9,14 +9,25 @@ export type LoggedProcessResult = {
|
|
|
9
9
|
stderr: string;
|
|
10
10
|
outputTail: string;
|
|
11
11
|
timedOut: boolean;
|
|
12
|
+
tokenUsage: AgentTokenUsage;
|
|
12
13
|
tokensUsed: number | null;
|
|
13
14
|
};
|
|
15
|
+
export type AgentTokenUsage = {
|
|
16
|
+
inputTokens: number | null;
|
|
17
|
+
outputTokens: number | null;
|
|
18
|
+
cacheCreationInputTokens: number | null;
|
|
19
|
+
cacheReadInputTokens: number | null;
|
|
20
|
+
totalTokens: number | null;
|
|
21
|
+
rawProviderUsage: Record<string, unknown> | null;
|
|
22
|
+
usageParseError: string | null;
|
|
23
|
+
};
|
|
14
24
|
export type LoggedProcessTranscriptChunk = {
|
|
15
25
|
stream: 'stdout' | 'stderr';
|
|
16
26
|
content: string;
|
|
17
27
|
};
|
|
18
28
|
export declare function readPositiveEnvInt(name: string, fallback: number): number;
|
|
19
29
|
export declare function readEnvBoolean(name: string, fallback: boolean): boolean;
|
|
30
|
+
export declare function extractAgentTokenUsage(output: string): AgentTokenUsage;
|
|
20
31
|
export declare function extractCodexTokensUsed(output: string): number | null;
|
|
21
32
|
export declare function assertWorkerTokenUsageWithinCap(input: {
|
|
22
33
|
tokensUsed: number | null;
|
|
@@ -62,6 +73,7 @@ export declare function buildClaudeDeniedPermissionRules(denyReadRoots?: string[
|
|
|
62
73
|
export declare function buildWorkerChildEnv(input: {
|
|
63
74
|
binDir: string;
|
|
64
75
|
taskId: number;
|
|
76
|
+
attempt: number;
|
|
65
77
|
envName: string;
|
|
66
78
|
eventDir?: string;
|
|
67
79
|
devPort?: number;
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.DEFAULT_TRANSCRIPT_FLUSH_INTERVAL_MS = exports.DEFAULT_CODEX_LOG_TAIL_CHARS = exports.DEFAULT_WORKER_TOKEN_CAP = exports.DEFAULT_CODEX_TIMEOUT_MS = void 0;
|
|
7
7
|
exports.readPositiveEnvInt = readPositiveEnvInt;
|
|
8
8
|
exports.readEnvBoolean = readEnvBoolean;
|
|
9
|
+
exports.extractAgentTokenUsage = extractAgentTokenUsage;
|
|
9
10
|
exports.extractCodexTokensUsed = extractCodexTokensUsed;
|
|
10
11
|
exports.assertWorkerTokenUsageWithinCap = assertWorkerTokenUsageWithinCap;
|
|
11
12
|
exports.runLoggedProcess = runLoggedProcess;
|
|
@@ -52,7 +53,45 @@ function readEnvBoolean(name, fallback) {
|
|
|
52
53
|
}
|
|
53
54
|
throw new Error(`invalid_${name.toLowerCase()}`);
|
|
54
55
|
}
|
|
55
|
-
function
|
|
56
|
+
function normalizeTokenCount(value) {
|
|
57
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return Math.ceil(value);
|
|
61
|
+
}
|
|
62
|
+
function coerceRecord(value) {
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function readStructuredUsage(parsed) {
|
|
69
|
+
const root = coerceRecord(parsed);
|
|
70
|
+
if (!root) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const directUsage = coerceRecord(root.usage);
|
|
74
|
+
if (directUsage) {
|
|
75
|
+
return directUsage;
|
|
76
|
+
}
|
|
77
|
+
const message = coerceRecord(root.message);
|
|
78
|
+
const messageUsage = coerceRecord(message?.usage);
|
|
79
|
+
if (messageUsage) {
|
|
80
|
+
return messageUsage;
|
|
81
|
+
}
|
|
82
|
+
const event = coerceRecord(root.event);
|
|
83
|
+
const eventUsage = coerceRecord(event?.usage);
|
|
84
|
+
if (eventUsage) {
|
|
85
|
+
return eventUsage;
|
|
86
|
+
}
|
|
87
|
+
const eventMessage = coerceRecord(event?.message);
|
|
88
|
+
const eventMessageUsage = coerceRecord(eventMessage?.usage);
|
|
89
|
+
if (eventMessageUsage) {
|
|
90
|
+
return eventMessageUsage;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function extractAgentTokenUsage(output) {
|
|
56
95
|
for (const line of output.split(/\r?\n/).reverse()) {
|
|
57
96
|
const trimmed = line.trim();
|
|
58
97
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
@@ -60,50 +99,49 @@ function extractCodexTokensUsed(output) {
|
|
|
60
99
|
}
|
|
61
100
|
try {
|
|
62
101
|
const parsed = JSON.parse(trimmed);
|
|
63
|
-
const usage = parsed
|
|
102
|
+
const usage = readStructuredUsage(parsed);
|
|
64
103
|
if (!usage) {
|
|
65
104
|
continue;
|
|
66
105
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
const inputTokens = normalizeTokenCount(usage.input_tokens);
|
|
107
|
+
const outputTokens = normalizeTokenCount(usage.output_tokens);
|
|
108
|
+
const cacheCreationInputTokens = normalizeTokenCount(usage.cache_creation_input_tokens);
|
|
109
|
+
const cacheReadInputTokens = normalizeTokenCount(usage.cache_read_input_tokens);
|
|
110
|
+
const tokenParts = [
|
|
111
|
+
inputTokens,
|
|
112
|
+
outputTokens,
|
|
113
|
+
cacheCreationInputTokens,
|
|
114
|
+
cacheReadInputTokens,
|
|
72
115
|
];
|
|
73
|
-
|
|
74
|
-
let sawTokenValue = false;
|
|
75
|
-
for (const key of tokenKeys) {
|
|
76
|
-
const value = usage[key];
|
|
77
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
total += value;
|
|
81
|
-
sawTokenValue = true;
|
|
82
|
-
}
|
|
116
|
+
const sawTokenValue = tokenParts.some((value) => value !== null);
|
|
83
117
|
if (sawTokenValue) {
|
|
84
|
-
return
|
|
118
|
+
return {
|
|
119
|
+
inputTokens,
|
|
120
|
+
outputTokens,
|
|
121
|
+
cacheCreationInputTokens,
|
|
122
|
+
cacheReadInputTokens,
|
|
123
|
+
totalTokens: tokenParts.reduce((sum, value) => sum + (value ?? 0), 0),
|
|
124
|
+
rawProviderUsage: usage,
|
|
125
|
+
usageParseError: null,
|
|
126
|
+
};
|
|
85
127
|
}
|
|
86
128
|
}
|
|
87
129
|
catch {
|
|
88
|
-
// Fall through to the plain-text patterns below.
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const patterns = [
|
|
92
|
-
/total\s+tokens\s*:?\s*([\d,]+)/i,
|
|
93
|
-
/tokens\s+used\s*:?\s*([\d,]+)/i,
|
|
94
|
-
/"total_tokens"\s*:\s*([\d,]+)/i,
|
|
95
|
-
];
|
|
96
|
-
for (const pattern of patterns) {
|
|
97
|
-
const match = pattern.exec(output);
|
|
98
|
-
if (!match?.[1]) {
|
|
99
130
|
continue;
|
|
100
131
|
}
|
|
101
|
-
const parsed = Number.parseInt(match[1].replace(/,/g, ''), 10);
|
|
102
|
-
if (Number.isInteger(parsed) && parsed >= 0) {
|
|
103
|
-
return parsed;
|
|
104
|
-
}
|
|
105
132
|
}
|
|
106
|
-
return
|
|
133
|
+
return {
|
|
134
|
+
inputTokens: null,
|
|
135
|
+
outputTokens: null,
|
|
136
|
+
cacheCreationInputTokens: null,
|
|
137
|
+
cacheReadInputTokens: null,
|
|
138
|
+
totalTokens: null,
|
|
139
|
+
rawProviderUsage: null,
|
|
140
|
+
usageParseError: 'agent_usage_not_reported',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function extractCodexTokensUsed(output) {
|
|
144
|
+
return extractAgentTokenUsage(output).totalTokens;
|
|
107
145
|
}
|
|
108
146
|
function assertWorkerTokenUsageWithinCap(input) {
|
|
109
147
|
if (input.tokensUsed === null) {
|
|
@@ -228,6 +266,7 @@ async function runLoggedProcess(input) {
|
|
|
228
266
|
throw transcriptFlushError;
|
|
229
267
|
}
|
|
230
268
|
const outputTail = tailText(combined, input.maxOutputChars);
|
|
269
|
+
const tokenUsage = extractAgentTokenUsage(`${stdout}\n${stderr}\n${outputTail}`);
|
|
231
270
|
resolve({
|
|
232
271
|
exitCode: code,
|
|
233
272
|
signal,
|
|
@@ -235,7 +274,8 @@ async function runLoggedProcess(input) {
|
|
|
235
274
|
stderr,
|
|
236
275
|
outputTail,
|
|
237
276
|
timedOut,
|
|
238
|
-
|
|
277
|
+
tokenUsage,
|
|
278
|
+
tokensUsed: tokenUsage.totalTokens,
|
|
239
279
|
});
|
|
240
280
|
})().catch(reject);
|
|
241
281
|
});
|
|
@@ -399,7 +439,11 @@ function buildWorkerChildEnv(input) {
|
|
|
399
439
|
}
|
|
400
440
|
}
|
|
401
441
|
child.PLAYDROP_WORKER_CONTEXT = '1';
|
|
442
|
+
if (!Number.isInteger(input.attempt) || input.attempt <= 0) {
|
|
443
|
+
throw new Error('invalid_worker_task_attempt');
|
|
444
|
+
}
|
|
402
445
|
child.PLAYDROP_WORKER_TASK_ID = String(input.taskId);
|
|
446
|
+
child.PLAYDROP_WORKER_TASK_ATTEMPT = String(input.attempt);
|
|
403
447
|
if (input.devPort !== undefined) {
|
|
404
448
|
if (!Number.isInteger(input.devPort) || input.devPort <= 0 || input.devPort > 65535) {
|
|
405
449
|
throw new Error('invalid_worker_task_dev_port');
|