@playdrop/playdrop-cli 0.9.6 → 0.10.1
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 -2
- package/dist/apiClient.d.ts +10 -0
- package/dist/apiClient.js +55 -2
- package/dist/appUrls.d.ts +1 -0
- package/dist/appUrls.js +9 -0
- package/dist/apps/build.js +39 -28
- package/dist/apps/index.d.ts +1 -0
- package/dist/apps/index.js +2 -0
- package/dist/apps/launchCheck.d.ts +2 -0
- package/dist/apps/launchCheck.js +31 -6
- package/dist/apps/registration.d.ts +1 -0
- package/dist/apps/registration.js +1 -0
- package/dist/apps/upload.d.ts +1 -0
- package/dist/apps/upload.js +4 -17
- package/dist/captureRuntime.d.ts +14 -0
- package/dist/captureRuntime.js +329 -0
- package/dist/catalogue.d.ts +4 -2
- package/dist/catalogue.js +50 -7
- package/dist/commandContext.js +61 -4
- package/dist/commands/capture.d.ts +1 -0
- package/dist/commands/capture.js +30 -13
- package/dist/commands/captureRemote.d.ts +2 -0
- package/dist/commands/captureRemote.js +90 -0
- package/dist/commands/create.d.ts +0 -1
- package/dist/commands/create.js +2 -151
- package/dist/commands/creations.d.ts +0 -13
- package/dist/commands/creations.js +0 -141
- package/dist/commands/dev.d.ts +2 -1
- package/dist/commands/dev.js +23 -6
- package/dist/commands/devServer.js +3 -1
- package/dist/commands/generation.d.ts +1 -0
- package/dist/commands/generation.js +274 -0
- package/dist/commands/review.d.ts +46 -0
- package/dist/commands/review.js +353 -0
- package/dist/commands/upload.d.ts +27 -1
- package/dist/commands/upload.js +962 -21
- package/dist/commands/validate.js +5 -0
- package/dist/commands/worker/runtime.d.ts +81 -0
- package/dist/commands/worker/runtime.js +458 -0
- package/dist/commands/worker.d.ts +158 -0
- package/dist/commands/worker.js +2626 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +23 -0
- package/dist/index.js +116 -30
- package/dist/shellProbe.d.ts +1 -1
- package/dist/shellProbe.js +3 -3
- package/dist/workspaceAuth.d.ts +3 -0
- package/dist/workspaceAuth.js +14 -0
- package/node_modules/@playdrop/api-client/dist/client.d.ts +36 -15
- package/node_modules/@playdrop/api-client/dist/client.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/client.js +2 -2
- package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts +5 -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 +51 -3
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts +75 -0
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts.map +1 -0
- package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.js +478 -0
- package/node_modules/@playdrop/api-client/dist/index.d.ts +36 -15
- package/node_modules/@playdrop/api-client/dist/index.d.ts.map +1 -1
- package/node_modules/@playdrop/api-client/dist/index.js +153 -42
- package/node_modules/@playdrop/config/client-meta.json +2 -2
- package/node_modules/@playdrop/types/dist/api.d.ts +662 -75
- package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/api.js +100 -9
- package/node_modules/@playdrop/types/dist/app.d.ts +2 -0
- package/node_modules/@playdrop/types/dist/app.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/app.js +3 -0
- package/node_modules/@playdrop/types/dist/version.d.ts +1 -0
- package/node_modules/@playdrop/types/dist/version.d.ts.map +1 -1
- package/package.json +2 -1
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.d.ts +0 -46
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.d.ts.map +0 -1
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.js +0 -177
|
@@ -0,0 +1,2626 @@
|
|
|
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.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = exports.WORKER_SESSION_EXPIRED_MESSAGE = exports.runLoggedProcess = exports.readCodexSandboxMode = exports.readEnvBoolean = exports.extractCodexTokensUsed = exports.extractAgentTokenUsage = exports.buildWorkerChildEnv = exports.buildClaudePermissionSettings = exports.buildClaudeExecArgs = exports.buildCodexExecArgs = exports.assertWorkerTokenUsageWithinCap = exports.DEFAULT_WORKER_TOKEN_CAP = exports.DEFAULT_CODEX_TIMEOUT_MS = void 0;
|
|
7
|
+
exports.allocateWorkerDevPort = allocateWorkerDevPort;
|
|
8
|
+
exports.isWorkerContextCommandAllowed = isWorkerContextCommandAllowed;
|
|
9
|
+
exports.resolveWorkerExecutionTargetFromRole = resolveWorkerExecutionTargetFromRole;
|
|
10
|
+
exports.resolveWorkerClaimTaskAssignment = resolveWorkerClaimTaskAssignment;
|
|
11
|
+
exports.nextClaimBackoffMs = nextClaimBackoffMs;
|
|
12
|
+
exports.claimBackoffDelayMs = claimBackoffDelayMs;
|
|
13
|
+
exports.resolveWorkerHomeDir = resolveWorkerHomeDir;
|
|
14
|
+
exports.hasAgentTaskUploadedArtifact = hasAgentTaskUploadedArtifact;
|
|
15
|
+
exports.appendWorkerTranscriptChunks = appendWorkerTranscriptChunks;
|
|
16
|
+
exports.assertTaskUploadResultMatchesContext = assertTaskUploadResultMatchesContext;
|
|
17
|
+
exports.stageAssignmentWorkspaceFiles = stageAssignmentWorkspaceFiles;
|
|
18
|
+
exports.resolvePlaydropPluginRoot = resolvePlaydropPluginRoot;
|
|
19
|
+
exports.stagePlaydropPluginReferences = stagePlaydropPluginReferences;
|
|
20
|
+
exports.discoverWorkerProjectRoot = discoverWorkerProjectRoot;
|
|
21
|
+
exports.readWorkerTaskState = readWorkerTaskState;
|
|
22
|
+
exports.buildWorkerHealthAlertText = buildWorkerHealthAlertText;
|
|
23
|
+
exports.isWorkerAuthFailureError = isWorkerAuthFailureError;
|
|
24
|
+
exports.classifyWorkerEventDrainError = classifyWorkerEventDrainError;
|
|
25
|
+
exports.assertWorkerClaimErrorRetryable = assertWorkerClaimErrorRetryable;
|
|
26
|
+
exports.resolvePlaydropAssetRequirementFromText = resolvePlaydropAssetRequirementFromText;
|
|
27
|
+
exports.loadWorkerEnvFile = loadWorkerEnvFile;
|
|
28
|
+
exports.buildWorkerCapabilities = buildWorkerCapabilities;
|
|
29
|
+
exports.buildWorkerClaimBody = buildWorkerClaimBody;
|
|
30
|
+
exports.resolveWorkerBaseSource = resolveWorkerBaseSource;
|
|
31
|
+
exports.extractZipArchive = extractZipArchive;
|
|
32
|
+
exports.readWorkerProjectVersion = readWorkerProjectVersion;
|
|
33
|
+
exports.buildCataloguePreviewPayload = buildCataloguePreviewPayload;
|
|
34
|
+
exports.assertWorkerProjectVersionBumped = assertWorkerProjectVersionBumped;
|
|
35
|
+
exports.buildAgentFailureCode = buildAgentFailureCode;
|
|
36
|
+
exports.describeAgentFailureForEvent = describeAgentFailureForEvent;
|
|
37
|
+
exports.startWorker = startWorker;
|
|
38
|
+
exports.reportTask = reportTask;
|
|
39
|
+
exports.reportCatalogueTask = reportCatalogueTask;
|
|
40
|
+
exports.uploadTask = uploadTask;
|
|
41
|
+
exports.completeTask = completeTask;
|
|
42
|
+
exports.readReviewEvidenceFiles = readReviewEvidenceFiles;
|
|
43
|
+
exports.submitReviewTask = submitReviewTask;
|
|
44
|
+
exports.failTask = failTask;
|
|
45
|
+
const promises_1 = require("node:fs/promises");
|
|
46
|
+
const node_fs_1 = require("node:fs");
|
|
47
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
48
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
49
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
50
|
+
const node_module_1 = require("node:module");
|
|
51
|
+
const fflate_1 = require("fflate");
|
|
52
|
+
const types_1 = require("@playdrop/types");
|
|
53
|
+
const commandContext_1 = require("../commandContext");
|
|
54
|
+
const config_1 = require("../config");
|
|
55
|
+
const clientInfo_1 = require("../clientInfo");
|
|
56
|
+
const messages_1 = require("../messages");
|
|
57
|
+
const output_1 = require("../output");
|
|
58
|
+
const shellProbe_1 = require("../shellProbe");
|
|
59
|
+
const upload_1 = require("./upload");
|
|
60
|
+
const review_1 = require("./review");
|
|
61
|
+
const runtime_1 = require("./worker/runtime");
|
|
62
|
+
var runtime_2 = require("./worker/runtime");
|
|
63
|
+
Object.defineProperty(exports, "DEFAULT_CODEX_TIMEOUT_MS", { enumerable: true, get: function () { return runtime_2.DEFAULT_CODEX_TIMEOUT_MS; } });
|
|
64
|
+
Object.defineProperty(exports, "DEFAULT_WORKER_TOKEN_CAP", { enumerable: true, get: function () { return runtime_2.DEFAULT_WORKER_TOKEN_CAP; } });
|
|
65
|
+
Object.defineProperty(exports, "assertWorkerTokenUsageWithinCap", { enumerable: true, get: function () { return runtime_2.assertWorkerTokenUsageWithinCap; } });
|
|
66
|
+
Object.defineProperty(exports, "buildCodexExecArgs", { enumerable: true, get: function () { return runtime_2.buildCodexExecArgs; } });
|
|
67
|
+
Object.defineProperty(exports, "buildClaudeExecArgs", { enumerable: true, get: function () { return runtime_2.buildClaudeExecArgs; } });
|
|
68
|
+
Object.defineProperty(exports, "buildClaudePermissionSettings", { enumerable: true, get: function () { return runtime_2.buildClaudePermissionSettings; } });
|
|
69
|
+
Object.defineProperty(exports, "buildWorkerChildEnv", { enumerable: true, get: function () { return runtime_2.buildWorkerChildEnv; } });
|
|
70
|
+
Object.defineProperty(exports, "extractAgentTokenUsage", { enumerable: true, get: function () { return runtime_2.extractAgentTokenUsage; } });
|
|
71
|
+
Object.defineProperty(exports, "extractCodexTokensUsed", { enumerable: true, get: function () { return runtime_2.extractCodexTokensUsed; } });
|
|
72
|
+
Object.defineProperty(exports, "readEnvBoolean", { enumerable: true, get: function () { return runtime_2.readEnvBoolean; } });
|
|
73
|
+
Object.defineProperty(exports, "readCodexSandboxMode", { enumerable: true, get: function () { return runtime_2.readCodexSandboxMode; } });
|
|
74
|
+
Object.defineProperty(exports, "runLoggedProcess", { enumerable: true, get: function () { return runtime_2.runLoggedProcess; } });
|
|
75
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
76
|
+
const HEARTBEAT_INTERVAL_MS = 10000;
|
|
77
|
+
const DEFAULT_WORKER_MAX_PARALLEL_TASKS = 5;
|
|
78
|
+
const DEFAULT_WORKER_DEV_PORT_BASE = 8900;
|
|
79
|
+
const CLAIM_BACKOFF_BASE_MS = 1000;
|
|
80
|
+
const CLAIM_BACKOFF_MAX_MS = 60000;
|
|
81
|
+
const CLAIM_BACKOFF_JITTER_MS = 500;
|
|
82
|
+
const FAIL_REPORT_RETRY_DELAY_MS = 2000;
|
|
83
|
+
const WORKER_TRANSCRIPT_CHUNK_BATCH_SIZE = 100;
|
|
84
|
+
const SLACK_API_BASE_URL = 'https://slack.com/api';
|
|
85
|
+
const WORKER_SUPPORTED_KINDS = ['NEW_GAME', 'GAME_UPDATE', 'GAME_REVIEW'];
|
|
86
|
+
const requireFromWorker = (0, node_module_1.createRequire)(__filename);
|
|
87
|
+
const PLAYDROP_PLUGIN_DAEMON_GAME_CREATION_SKILL_PATH = 'skills/daemon-game-creation/SKILL.md';
|
|
88
|
+
const PLAYDROP_PLUGIN_GAME_IMPROVEMENT_SKILL_PATH = 'skills/game-improvement/SKILL.md';
|
|
89
|
+
const PLAYDROP_PLUGIN_ASSET_DISCOVERY_SKILL_PATH = 'skills/asset-discovery/SKILL.md';
|
|
90
|
+
const PLAYDROP_PLUGIN_ASSET_EXTRACTION_SKILL_PATH = 'skills/asset-extraction-2d/SKILL.md';
|
|
91
|
+
const PLAYDROP_PLUGIN_LISTING_ART_SKILL_PATH = 'skills/listing-art/SKILL.md';
|
|
92
|
+
const PLAYDROP_PLUGIN_GAME_REVIEW_SKILL_PATH = 'skills/game-review/SKILL.md';
|
|
93
|
+
const PLAYDROP_PLUGIN_ASSET_REUSE_REFERENCE_PATH = 'references/asset-reuse.md';
|
|
94
|
+
const PLAYDROP_PLUGIN_ASSETS_AND_GENERATION_REFERENCE_PATH = 'references/assets-and-generation.md';
|
|
95
|
+
const PLAYDROP_PLUGIN_CODE_REUSE_REFERENCE_PATH = 'references/code-reuse.md';
|
|
96
|
+
const PLAYDROP_PLUGIN_EXTRACTOR_PATH = 'scripts/extract-alpha-background-swap.ts';
|
|
97
|
+
const PLAYDROP_PLUGIN_LISTING_ICON_SCRIPT_PATH = 'scripts/compose-listing-icon.ts';
|
|
98
|
+
const PLAYDROP_PLUGIN_LISTING_TITLE_SCRIPT_PATH = 'scripts/compose-listing-title.ts';
|
|
99
|
+
const STAGED_PLAYDROP_PLUGIN_ROOT = '.playdrop/plugin';
|
|
100
|
+
const PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES = [
|
|
101
|
+
PLAYDROP_PLUGIN_DAEMON_GAME_CREATION_SKILL_PATH,
|
|
102
|
+
PLAYDROP_PLUGIN_GAME_IMPROVEMENT_SKILL_PATH,
|
|
103
|
+
PLAYDROP_PLUGIN_ASSET_DISCOVERY_SKILL_PATH,
|
|
104
|
+
PLAYDROP_PLUGIN_ASSET_EXTRACTION_SKILL_PATH,
|
|
105
|
+
PLAYDROP_PLUGIN_LISTING_ART_SKILL_PATH,
|
|
106
|
+
PLAYDROP_PLUGIN_ASSET_REUSE_REFERENCE_PATH,
|
|
107
|
+
PLAYDROP_PLUGIN_ASSETS_AND_GENERATION_REFERENCE_PATH,
|
|
108
|
+
PLAYDROP_PLUGIN_CODE_REUSE_REFERENCE_PATH,
|
|
109
|
+
PLAYDROP_PLUGIN_EXTRACTOR_PATH,
|
|
110
|
+
PLAYDROP_PLUGIN_LISTING_ICON_SCRIPT_PATH,
|
|
111
|
+
PLAYDROP_PLUGIN_LISTING_TITLE_SCRIPT_PATH,
|
|
112
|
+
];
|
|
113
|
+
const PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES = [
|
|
114
|
+
PLAYDROP_PLUGIN_GAME_REVIEW_SKILL_PATH,
|
|
115
|
+
'references/game-review-criteria.md',
|
|
116
|
+
'references/game-review-comparative-method.md',
|
|
117
|
+
'references/game-review-evidence-capture.md',
|
|
118
|
+
'references/game-review-rating-scale.md',
|
|
119
|
+
'references/game-review-score-caps.md',
|
|
120
|
+
'references/game-review-gates.md',
|
|
121
|
+
'references/game-review-gates/concept.md',
|
|
122
|
+
'references/game-review-gates/gameplay-prototype.md',
|
|
123
|
+
'references/game-review-gates/demo.md',
|
|
124
|
+
'references/game-review-gates/vertical-slice.md',
|
|
125
|
+
'references/game-review-gates/first-release.md',
|
|
126
|
+
'references/game-review-gates/mature-live-version.md',
|
|
127
|
+
'references/game-review-outcomes.md',
|
|
128
|
+
'references/game-review-feedback-format.md',
|
|
129
|
+
'references/game-review-dimensions/gameplay-core-loop.md',
|
|
130
|
+
'references/game-review-dimensions/depth-replayability.md',
|
|
131
|
+
'references/game-review-dimensions/controls-input.md',
|
|
132
|
+
'references/game-review-dimensions/ux-usability.md',
|
|
133
|
+
'references/game-review-dimensions/first-time-user-experience.md',
|
|
134
|
+
'references/game-review-dimensions/visuals-art-direction.md',
|
|
135
|
+
'references/game-review-dimensions/audio-feedback.md',
|
|
136
|
+
'references/game-review-dimensions/store-listing-metadata-accuracy.md',
|
|
137
|
+
'references/game-review-dimensions/safety-age-rating-compliance.md',
|
|
138
|
+
'references/game-review-dimensions/performance-stability.md',
|
|
139
|
+
];
|
|
140
|
+
const PLAYDROP_PLUGIN_REFERENCE_FILES = [
|
|
141
|
+
...PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES,
|
|
142
|
+
...PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES,
|
|
143
|
+
];
|
|
144
|
+
exports.WORKER_SESSION_EXPIRED_MESSAGE = 'worker session expired: run "playdrop auth login" and start the worker again';
|
|
145
|
+
const WORKER_CONTEXT_ALLOWED_COMMANDS = [
|
|
146
|
+
['task', 'report'],
|
|
147
|
+
['task', 'report-catalogue'],
|
|
148
|
+
['task', 'upload'],
|
|
149
|
+
['task', 'done'],
|
|
150
|
+
['task', 'submit-review'],
|
|
151
|
+
['task', 'fail'],
|
|
152
|
+
['review', 'validate-result'],
|
|
153
|
+
['review', 'compose-evidence'],
|
|
154
|
+
['review', 'rating-card'],
|
|
155
|
+
['project', 'validate'],
|
|
156
|
+
['project', 'build'],
|
|
157
|
+
['project', 'dev'],
|
|
158
|
+
['project', 'capture'],
|
|
159
|
+
['browse'],
|
|
160
|
+
['search'],
|
|
161
|
+
['detail'],
|
|
162
|
+
['versions', 'browse'],
|
|
163
|
+
['documentation', 'browse'],
|
|
164
|
+
['documentation', 'read'],
|
|
165
|
+
['ai', 'create'],
|
|
166
|
+
['ai', 'jobs', 'browse'],
|
|
167
|
+
['ai', 'jobs', 'detail'],
|
|
168
|
+
['ai', 'generations'],
|
|
169
|
+
];
|
|
170
|
+
exports.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = 'worker_context_command_not_allowed: inside a PlayDrop worker task only task progress, task upload/done/fail, project validation/build/dev/capture, read-only catalogue/documentation lookup, and PlayDrop AI generation are permitted.';
|
|
171
|
+
function allocateWorkerDevPort(input) {
|
|
172
|
+
const basePort = input.basePort ?? DEFAULT_WORKER_DEV_PORT_BASE;
|
|
173
|
+
const maxParallelTasks = input.maxParallelTasks ?? DEFAULT_WORKER_MAX_PARALLEL_TASKS;
|
|
174
|
+
if (!Number.isInteger(basePort) || basePort <= 0 || basePort > 65535) {
|
|
175
|
+
throw new Error('invalid_worker_dev_port_base');
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isInteger(maxParallelTasks) || maxParallelTasks <= 0) {
|
|
178
|
+
throw new Error('invalid_worker_max_parallel_tasks');
|
|
179
|
+
}
|
|
180
|
+
for (let offset = 0; offset < maxParallelTasks; offset += 1) {
|
|
181
|
+
const port = basePort + offset;
|
|
182
|
+
if (port > 65535) {
|
|
183
|
+
throw new Error('worker_dev_port_range_exhausted');
|
|
184
|
+
}
|
|
185
|
+
if (!input.activePorts.has(port)) {
|
|
186
|
+
return port;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw new Error('worker_dev_port_pool_exhausted');
|
|
190
|
+
}
|
|
191
|
+
function sleep(ms) {
|
|
192
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
193
|
+
}
|
|
194
|
+
function isWorkerContextCommandAllowed(argv) {
|
|
195
|
+
return WORKER_CONTEXT_ALLOWED_COMMANDS.some((allowed) => allowed.every((part, index) => argv[index] === part));
|
|
196
|
+
}
|
|
197
|
+
function resolveWorkerExecutionTargetFromRole(role) {
|
|
198
|
+
return role === 'ADMIN' ? 'FIRST_PARTY' : 'PERSONAL';
|
|
199
|
+
}
|
|
200
|
+
function normalizeAssignmentWorkspaceFolder(value) {
|
|
201
|
+
const folderName = typeof value === 'string' ? value.trim() : '';
|
|
202
|
+
if (!folderName) {
|
|
203
|
+
throw new Error('agent_task_assignment_workspace_missing');
|
|
204
|
+
}
|
|
205
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(folderName) || folderName === '.' || folderName === '..') {
|
|
206
|
+
throw new Error('agent_task_assignment_workspace_unsafe');
|
|
207
|
+
}
|
|
208
|
+
return folderName;
|
|
209
|
+
}
|
|
210
|
+
function normalizeAssignmentAgent(value) {
|
|
211
|
+
if (value !== 'CODEX' && value !== 'CLAUDE_CODE') {
|
|
212
|
+
throw new Error('agent_task_assignment_agent_missing');
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function normalizeAssignmentKind(value, task) {
|
|
217
|
+
if (value !== 'NEW_GAME' && value !== 'GAME_UPDATE' && value !== 'GAME_REVIEW') {
|
|
218
|
+
throw new Error('agent_task_assignment_kind_missing');
|
|
219
|
+
}
|
|
220
|
+
if (value !== task.kind) {
|
|
221
|
+
throw new Error('agent_task_assignment_kind_mismatch');
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
function normalizeAssignmentTarget(value) {
|
|
226
|
+
if (value !== 'FIRST_PARTY' && value !== 'PERSONAL') {
|
|
227
|
+
throw new Error('agent_task_assignment_target_missing');
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
function resolveWorkerClaimTaskAssignment(claim) {
|
|
232
|
+
if (!claim.task) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const assignment = claim.taskAssignment;
|
|
236
|
+
if (!assignment || typeof assignment !== 'object') {
|
|
237
|
+
throw new Error('agent_task_claim_missing_task_assignment');
|
|
238
|
+
}
|
|
239
|
+
const prompt = typeof assignment.prompt === 'string' ? assignment.prompt.trim() : '';
|
|
240
|
+
if (!prompt) {
|
|
241
|
+
throw new Error('agent_task_assignment_prompt_missing');
|
|
242
|
+
}
|
|
243
|
+
const taskId = parsePositiveInteger(assignment.taskId, 'agent_task_assignment_task_id_missing');
|
|
244
|
+
if (taskId !== claim.task.id) {
|
|
245
|
+
throw new Error('agent_task_assignment_task_id_mismatch');
|
|
246
|
+
}
|
|
247
|
+
const taskToken = typeof assignment.taskToken === 'string' ? assignment.taskToken.trim() : '';
|
|
248
|
+
if (!taskToken) {
|
|
249
|
+
throw new Error('agent_task_assignment_token_missing');
|
|
250
|
+
}
|
|
251
|
+
const model = typeof assignment.model === 'string' ? assignment.model.trim() : '';
|
|
252
|
+
if (!model) {
|
|
253
|
+
throw new Error('agent_task_assignment_model_missing');
|
|
254
|
+
}
|
|
255
|
+
const workspace = assignment.workspace && typeof assignment.workspace === 'object' && !Array.isArray(assignment.workspace)
|
|
256
|
+
? assignment.workspace
|
|
257
|
+
: null;
|
|
258
|
+
if (!workspace) {
|
|
259
|
+
throw new Error('agent_task_assignment_workspace_missing');
|
|
260
|
+
}
|
|
261
|
+
const inputs = assignment.inputs && typeof assignment.inputs === 'object' && !Array.isArray(assignment.inputs)
|
|
262
|
+
? assignment.inputs
|
|
263
|
+
: null;
|
|
264
|
+
if (!inputs) {
|
|
265
|
+
throw new Error('agent_task_assignment_inputs_missing');
|
|
266
|
+
}
|
|
267
|
+
const output = assignment.output && typeof assignment.output === 'object' && !Array.isArray(assignment.output)
|
|
268
|
+
? assignment.output
|
|
269
|
+
: null;
|
|
270
|
+
if (!output) {
|
|
271
|
+
throw new Error('agent_task_assignment_output_missing');
|
|
272
|
+
}
|
|
273
|
+
const version = typeof output.version === 'string' ? output.version.trim() : '';
|
|
274
|
+
if (!version) {
|
|
275
|
+
throw new Error('agent_task_assignment_output_version_missing');
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
taskId,
|
|
279
|
+
taskToken,
|
|
280
|
+
kind: normalizeAssignmentKind(assignment.kind, claim.task),
|
|
281
|
+
target: normalizeAssignmentTarget(assignment.target),
|
|
282
|
+
agent: normalizeAssignmentAgent(assignment.agent),
|
|
283
|
+
model,
|
|
284
|
+
workspace: {
|
|
285
|
+
folderName: normalizeAssignmentWorkspaceFolder(workspace.folderName),
|
|
286
|
+
files: normalizeAssignmentWorkspaceFiles(workspace.files),
|
|
287
|
+
},
|
|
288
|
+
inputs: {
|
|
289
|
+
request: {
|
|
290
|
+
prompt: normalizeAssignmentRequest(inputs.request),
|
|
291
|
+
},
|
|
292
|
+
attachments: Array.isArray(inputs.attachments) ? inputs.attachments : [],
|
|
293
|
+
baseSource: inputs.baseSource ?? null,
|
|
294
|
+
reviewTarget: inputs.reviewTarget ?? null,
|
|
295
|
+
},
|
|
296
|
+
output: {
|
|
297
|
+
appId: typeof output.appId === 'number' && Number.isInteger(output.appId) && output.appId > 0 ? output.appId : null,
|
|
298
|
+
appName: typeof output.appName === 'string' && output.appName.trim() ? output.appName.trim() : null,
|
|
299
|
+
version,
|
|
300
|
+
releaseNoteRequired: output.releaseNoteRequired !== false,
|
|
301
|
+
},
|
|
302
|
+
prompt,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function nextClaimBackoffMs(currentMs) {
|
|
306
|
+
return Math.min(Math.max(currentMs, CLAIM_BACKOFF_BASE_MS) * 2, CLAIM_BACKOFF_MAX_MS);
|
|
307
|
+
}
|
|
308
|
+
function claimBackoffDelayMs(currentMs, random = Math.random) {
|
|
309
|
+
return currentMs + Math.floor(random() * CLAIM_BACKOFF_JITTER_MS);
|
|
310
|
+
}
|
|
311
|
+
function resolveWorkerHomeDir() {
|
|
312
|
+
const custom = node_process_1.default.env.PLAYDROP_WORKER_HOME?.trim();
|
|
313
|
+
if (custom) {
|
|
314
|
+
return custom;
|
|
315
|
+
}
|
|
316
|
+
return node_path_1.default.join(node_os_1.default.homedir(), 'PlayDropWorker');
|
|
317
|
+
}
|
|
318
|
+
function workerStateFilePath() {
|
|
319
|
+
return node_path_1.default.join(resolveWorkerHomeDir(), 'state', 'current-task.json');
|
|
320
|
+
}
|
|
321
|
+
function workerTaskWorkspaceDirFromAssignment(assignment) {
|
|
322
|
+
return node_path_1.default.join(resolveWorkerHomeDir(), 'tasks', assignment.workspace.folderName);
|
|
323
|
+
}
|
|
324
|
+
function buildWorkerTaskContext(claim, assignment) {
|
|
325
|
+
const task = claim.task;
|
|
326
|
+
if (!task) {
|
|
327
|
+
throw new Error('agent_task_claim_missing_task');
|
|
328
|
+
}
|
|
329
|
+
if (task.id !== assignment.taskId) {
|
|
330
|
+
throw new Error('agent_task_context_task_id_mismatch');
|
|
331
|
+
}
|
|
332
|
+
if (task.kind !== assignment.kind) {
|
|
333
|
+
throw new Error('agent_task_context_kind_mismatch');
|
|
334
|
+
}
|
|
335
|
+
if (task.executionTarget !== assignment.target) {
|
|
336
|
+
throw new Error('agent_task_context_target_mismatch');
|
|
337
|
+
}
|
|
338
|
+
const creatorUsername = typeof task.creatorUsername === 'string' ? task.creatorUsername.trim() : '';
|
|
339
|
+
const outputVersion = typeof assignment.output.version === 'string' ? assignment.output.version.trim() : '';
|
|
340
|
+
if (!creatorUsername || !outputVersion) {
|
|
341
|
+
throw new Error('agent_task_context_invalid');
|
|
342
|
+
}
|
|
343
|
+
const taskToken = typeof assignment.taskToken === 'string' ? assignment.taskToken.trim() : '';
|
|
344
|
+
if (!taskToken) {
|
|
345
|
+
throw new Error('agent_task_context_token_missing');
|
|
346
|
+
}
|
|
347
|
+
const attempt = Number(task.attempts);
|
|
348
|
+
if (!Number.isInteger(attempt) || attempt <= 0) {
|
|
349
|
+
throw new Error('agent_task_context_attempt_missing');
|
|
350
|
+
}
|
|
351
|
+
const outputAppName = typeof assignment.output.appName === 'string' && assignment.output.appName.trim()
|
|
352
|
+
? assignment.output.appName.trim()
|
|
353
|
+
: null;
|
|
354
|
+
const reviewTarget = assignment.inputs.reviewTarget && typeof assignment.inputs.reviewTarget === 'object'
|
|
355
|
+
? assignment.inputs.reviewTarget
|
|
356
|
+
: null;
|
|
357
|
+
let reviewAppVersionId = null;
|
|
358
|
+
if (assignment.kind === 'GAME_REVIEW') {
|
|
359
|
+
const parsedReviewAppVersionId = Number(reviewTarget?.appVersionId);
|
|
360
|
+
if (!Number.isInteger(parsedReviewAppVersionId) || parsedReviewAppVersionId <= 0) {
|
|
361
|
+
throw new Error('agent_task_context_review_target_missing');
|
|
362
|
+
}
|
|
363
|
+
reviewAppVersionId = parsedReviewAppVersionId;
|
|
364
|
+
}
|
|
365
|
+
const creatorRequest = assignment.inputs.request.prompt.trim();
|
|
366
|
+
if (!creatorRequest) {
|
|
367
|
+
throw new Error('agent_task_context_creator_request_missing');
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
taskId: task.id,
|
|
371
|
+
attempt,
|
|
372
|
+
taskToken,
|
|
373
|
+
kind: assignment.kind,
|
|
374
|
+
creatorUsername,
|
|
375
|
+
creatorRequest,
|
|
376
|
+
target: assignment.target,
|
|
377
|
+
outputAppName,
|
|
378
|
+
outputVersion,
|
|
379
|
+
reviewAppVersionId,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function workerTaskContextPath(workspaceDir) {
|
|
383
|
+
return node_path_1.default.join(workspaceDir, '.playdrop', 'task.json');
|
|
384
|
+
}
|
|
385
|
+
function workerTaskUploadResultPath(workspaceDir) {
|
|
386
|
+
return node_path_1.default.join(workspaceDir, '.playdrop', 'task-upload-result.json');
|
|
387
|
+
}
|
|
388
|
+
async function stageWorkerTaskContext(input) {
|
|
389
|
+
const playdropDir = node_path_1.default.join(input.workspaceDir, '.playdrop');
|
|
390
|
+
await (0, promises_1.mkdir)(playdropDir, { recursive: true });
|
|
391
|
+
const taskFile = {
|
|
392
|
+
...input.taskContext,
|
|
393
|
+
devPort: input.devPort,
|
|
394
|
+
env: input.env,
|
|
395
|
+
};
|
|
396
|
+
await (0, promises_1.writeFile)(workerTaskContextPath(input.workspaceDir), JSON.stringify(taskFile, null, 2), 'utf8');
|
|
397
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(input.workspaceDir, '.playdrop.json'), JSON.stringify({
|
|
398
|
+
ownerUsername: input.taskContext.creatorUsername,
|
|
399
|
+
env: input.env,
|
|
400
|
+
taskId: input.taskContext.taskId,
|
|
401
|
+
taskAttempt: input.taskContext.attempt,
|
|
402
|
+
devPort: input.devPort,
|
|
403
|
+
taskToken: input.taskContext.taskToken,
|
|
404
|
+
}, null, 2), 'utf8');
|
|
405
|
+
}
|
|
406
|
+
function findWorkspaceTaskFile(startDir, basename) {
|
|
407
|
+
let current = node_path_1.default.resolve(startDir);
|
|
408
|
+
while (true) {
|
|
409
|
+
const candidate = node_path_1.default.join(current, '.playdrop', basename);
|
|
410
|
+
if ((0, node_fs_1.existsSync)(candidate)) {
|
|
411
|
+
return candidate;
|
|
412
|
+
}
|
|
413
|
+
const parent = node_path_1.default.dirname(current);
|
|
414
|
+
if (parent === current) {
|
|
415
|
+
throw new Error(`task_context_missing:${basename}`);
|
|
416
|
+
}
|
|
417
|
+
current = parent;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function readTaskContextFile(startDir = node_process_1.default.cwd()) {
|
|
421
|
+
const file = findWorkspaceTaskFile(startDir, 'task.json');
|
|
422
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
423
|
+
const taskId = Number(parsed.taskId);
|
|
424
|
+
const attempt = Number(parsed.attempt);
|
|
425
|
+
const taskToken = typeof parsed.taskToken === 'string' ? parsed.taskToken.trim() : '';
|
|
426
|
+
const creatorUsername = typeof parsed.creatorUsername === 'string' ? parsed.creatorUsername.trim() : '';
|
|
427
|
+
const env = typeof parsed.env === 'string' ? parsed.env.trim() : '';
|
|
428
|
+
const creatorRequest = typeof parsed.creatorRequest === 'string' ? parsed.creatorRequest.trim() : '';
|
|
429
|
+
const outputVersion = typeof parsed.outputVersion === 'string' ? parsed.outputVersion.trim() : '';
|
|
430
|
+
let reviewAppVersionId = null;
|
|
431
|
+
if (parsed.reviewAppVersionId !== null && parsed.reviewAppVersionId !== undefined) {
|
|
432
|
+
const parsedReviewAppVersionId = Number(parsed.reviewAppVersionId);
|
|
433
|
+
if (Number.isInteger(parsedReviewAppVersionId) && parsedReviewAppVersionId > 0) {
|
|
434
|
+
reviewAppVersionId = parsedReviewAppVersionId;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const devPort = typeof parsed.devPort === 'number' ? parsed.devPort : Number.parseInt(String(parsed.devPort ?? ''), 10);
|
|
438
|
+
if (!Number.isInteger(taskId) || taskId <= 0
|
|
439
|
+
|| !Number.isInteger(attempt) || attempt <= 0
|
|
440
|
+
|| !taskToken
|
|
441
|
+
|| (parsed.kind !== 'NEW_GAME' && parsed.kind !== 'GAME_UPDATE' && parsed.kind !== 'GAME_REVIEW')
|
|
442
|
+
|| (parsed.target !== 'FIRST_PARTY' && parsed.target !== 'PERSONAL')
|
|
443
|
+
|| !creatorUsername
|
|
444
|
+
|| !creatorRequest
|
|
445
|
+
|| !env
|
|
446
|
+
|| !outputVersion
|
|
447
|
+
|| (parsed.kind === 'GAME_REVIEW' && reviewAppVersionId === null)
|
|
448
|
+
|| !Number.isInteger(devPort) || devPort <= 0 || devPort > 65535) {
|
|
449
|
+
throw new Error(`task_context_invalid:${file}`);
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
taskId,
|
|
453
|
+
attempt,
|
|
454
|
+
taskToken,
|
|
455
|
+
kind: parsed.kind,
|
|
456
|
+
creatorUsername,
|
|
457
|
+
creatorRequest,
|
|
458
|
+
target: parsed.target,
|
|
459
|
+
outputAppName: typeof parsed.outputAppName === 'string' && parsed.outputAppName.trim() ? parsed.outputAppName.trim() : null,
|
|
460
|
+
outputVersion,
|
|
461
|
+
reviewAppVersionId,
|
|
462
|
+
devPort,
|
|
463
|
+
env,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function normalizeAssignmentRequest(value) {
|
|
467
|
+
const request = value && typeof value === 'object' && !Array.isArray(value)
|
|
468
|
+
? value
|
|
469
|
+
: null;
|
|
470
|
+
const prompt = typeof request?.prompt === 'string' ? request.prompt.trim() : '';
|
|
471
|
+
if (!prompt) {
|
|
472
|
+
throw new Error('agent_task_assignment_request_prompt_missing');
|
|
473
|
+
}
|
|
474
|
+
return prompt;
|
|
475
|
+
}
|
|
476
|
+
function hasAgentTaskUploadedArtifact(task) {
|
|
477
|
+
return ((Number.isInteger(task.appId) && Number(task.appId) > 0)
|
|
478
|
+
|| (Number.isInteger(task.appVersionId) && Number(task.appVersionId) > 0));
|
|
479
|
+
}
|
|
480
|
+
async function appendWorkerTranscriptChunks(input) {
|
|
481
|
+
const batchSize = input.batchSize ?? WORKER_TRANSCRIPT_CHUNK_BATCH_SIZE;
|
|
482
|
+
if (!Number.isInteger(batchSize) || batchSize <= 0 || batchSize > WORKER_TRANSCRIPT_CHUNK_BATCH_SIZE) {
|
|
483
|
+
throw new Error(`invalid_worker_transcript_chunk_batch_size:${batchSize}`);
|
|
484
|
+
}
|
|
485
|
+
for (let index = 0; index < input.chunks.length; index += batchSize) {
|
|
486
|
+
await input.client.workerAppendAgentTaskTranscriptChunks(input.taskId, {
|
|
487
|
+
workerKey: input.workerKey,
|
|
488
|
+
leaseToken: input.leaseToken,
|
|
489
|
+
chunks: input.chunks.slice(index, index + batchSize),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function resolvePlaydropAssetRequirementFromTaskRequest(value) {
|
|
494
|
+
const text = typeof value === 'string' ? value.toLowerCase() : '';
|
|
495
|
+
if (!text.trim()) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const mentionsPlaydrop = /\bplay\s*drop\b|\bplaydrop\b/.test(text);
|
|
499
|
+
if (!mentionsPlaydrop) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
if (/\basset\s*pack(s)?\b|\bpack(s)?\b/.test(text)) {
|
|
503
|
+
return 'PACK';
|
|
504
|
+
}
|
|
505
|
+
if (/\basset(s)?\b|\bsprite(s)?\b|\bmodel(s)?\b|\bart\b|\bcharacter(s)?\b|\bprop(s)?\b/.test(text)) {
|
|
506
|
+
return 'ASSET_OR_PACK';
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
function resolveTaskWorkspaceDir(startDir = node_process_1.default.cwd()) {
|
|
511
|
+
return node_path_1.default.dirname(node_path_1.default.dirname(findWorkspaceTaskFile(startDir, 'task.json')));
|
|
512
|
+
}
|
|
513
|
+
function readTaskUploadResultFile(startDir = node_process_1.default.cwd()) {
|
|
514
|
+
const file = findWorkspaceTaskFile(startDir, 'task-upload-result.json');
|
|
515
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
516
|
+
const taskId = Number(parsed.taskId);
|
|
517
|
+
const appId = Number(parsed.appId);
|
|
518
|
+
const appVersionId = Number(parsed.appVersionId);
|
|
519
|
+
const appName = typeof parsed.appName === 'string' ? parsed.appName.trim() : '';
|
|
520
|
+
const version = typeof parsed.version === 'string' ? parsed.version.trim() : '';
|
|
521
|
+
const versionNodeId = typeof parsed.versionNodeId === 'string' ? parsed.versionNodeId.trim() : '';
|
|
522
|
+
const creatorUsername = typeof parsed.creatorUsername === 'string' ? parsed.creatorUsername.trim() : '';
|
|
523
|
+
if (!Number.isInteger(taskId) || taskId <= 0
|
|
524
|
+
|| !Number.isInteger(appId) || appId <= 0
|
|
525
|
+
|| !Number.isInteger(appVersionId) || appVersionId <= 0
|
|
526
|
+
|| !appName
|
|
527
|
+
|| !version
|
|
528
|
+
|| !versionNodeId
|
|
529
|
+
|| !creatorUsername) {
|
|
530
|
+
throw new Error(`task_upload_result_invalid:${file}`);
|
|
531
|
+
}
|
|
532
|
+
return { taskId, appId, appVersionId, appName, version, versionNodeId, creatorUsername };
|
|
533
|
+
}
|
|
534
|
+
function assertTaskUploadResultMatchesContext(input) {
|
|
535
|
+
if (input.uploadResult.taskId !== input.taskContext.taskId) {
|
|
536
|
+
throw new Error('task_upload_result_task_mismatch');
|
|
537
|
+
}
|
|
538
|
+
if (input.uploadResult.creatorUsername !== input.taskContext.creatorUsername) {
|
|
539
|
+
throw new Error('task_upload_result_creator_mismatch');
|
|
540
|
+
}
|
|
541
|
+
if (input.uploadResult.version !== input.taskContext.outputVersion) {
|
|
542
|
+
throw new Error('task_upload_result_version_mismatch');
|
|
543
|
+
}
|
|
544
|
+
const expectedAppName = typeof input.taskContext.outputAppName === 'string'
|
|
545
|
+
? input.taskContext.outputAppName.trim()
|
|
546
|
+
: '';
|
|
547
|
+
if (expectedAppName && input.uploadResult.appName !== expectedAppName) {
|
|
548
|
+
throw new Error('task_upload_result_app_mismatch');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function normalizeAssignmentWorkspaceRelativePath(value) {
|
|
552
|
+
if (typeof value !== 'string') {
|
|
553
|
+
throw new Error('agent_task_assignment_workspace_file_path_invalid');
|
|
554
|
+
}
|
|
555
|
+
const normalizedInput = value.trim().replace(/\\/g, '/');
|
|
556
|
+
if (!normalizedInput || normalizedInput.includes('\0') || normalizedInput.startsWith('/')) {
|
|
557
|
+
throw new Error('agent_task_assignment_workspace_file_path_invalid');
|
|
558
|
+
}
|
|
559
|
+
const normalizedPath = node_path_1.default.posix.normalize(normalizedInput);
|
|
560
|
+
if (!normalizedPath
|
|
561
|
+
|| normalizedPath === '.'
|
|
562
|
+
|| normalizedPath === '..'
|
|
563
|
+
|| normalizedPath.startsWith('../')) {
|
|
564
|
+
throw new Error('agent_task_assignment_workspace_file_path_invalid');
|
|
565
|
+
}
|
|
566
|
+
return normalizedPath;
|
|
567
|
+
}
|
|
568
|
+
function normalizeAssignmentWorkspaceFile(value) {
|
|
569
|
+
const file = value && typeof value === 'object' && !Array.isArray(value)
|
|
570
|
+
? value
|
|
571
|
+
: null;
|
|
572
|
+
if (!file) {
|
|
573
|
+
throw new Error('agent_task_assignment_workspace_file_invalid');
|
|
574
|
+
}
|
|
575
|
+
const relativePath = normalizeAssignmentWorkspaceRelativePath(file.relativePath);
|
|
576
|
+
const contentBase64 = typeof file.contentBase64 === 'string' ? file.contentBase64.trim() : '';
|
|
577
|
+
if (!contentBase64 || contentBase64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(contentBase64)) {
|
|
578
|
+
throw new Error('agent_task_assignment_workspace_file_content_invalid');
|
|
579
|
+
}
|
|
580
|
+
const contentType = typeof file.contentType === 'string' && file.contentType.trim()
|
|
581
|
+
? file.contentType.trim()
|
|
582
|
+
: undefined;
|
|
583
|
+
return {
|
|
584
|
+
relativePath,
|
|
585
|
+
contentBase64,
|
|
586
|
+
...(contentType ? { contentType } : {}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function normalizeAssignmentWorkspaceFiles(value) {
|
|
590
|
+
if (!Array.isArray(value)) {
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
const seen = new Set();
|
|
594
|
+
return value.map((entry) => {
|
|
595
|
+
const normalized = normalizeAssignmentWorkspaceFile(entry);
|
|
596
|
+
if (seen.has(normalized.relativePath)) {
|
|
597
|
+
throw new Error('agent_task_assignment_workspace_file_duplicate');
|
|
598
|
+
}
|
|
599
|
+
seen.add(normalized.relativePath);
|
|
600
|
+
return normalized;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function resolveWorkspaceFileDestination(workspaceDir, relativePath) {
|
|
604
|
+
const root = node_path_1.default.resolve(workspaceDir);
|
|
605
|
+
const destination = node_path_1.default.resolve(root, ...relativePath.split('/'));
|
|
606
|
+
const relative = node_path_1.default.relative(root, destination);
|
|
607
|
+
if (!relative || relative.startsWith('..') || node_path_1.default.isAbsolute(relative)) {
|
|
608
|
+
throw new Error('agent_task_assignment_workspace_file_path_invalid');
|
|
609
|
+
}
|
|
610
|
+
return destination;
|
|
611
|
+
}
|
|
612
|
+
async function stageAssignmentWorkspaceFiles(workspaceDir, files) {
|
|
613
|
+
const normalizedFiles = normalizeAssignmentWorkspaceFiles(files);
|
|
614
|
+
for (const file of normalizedFiles) {
|
|
615
|
+
const destination = resolveWorkspaceFileDestination(workspaceDir, file.relativePath);
|
|
616
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
|
|
617
|
+
await (0, promises_1.writeFile)(destination, Buffer.from(file.contentBase64, 'base64'));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function candidateVersionDirectories(root) {
|
|
621
|
+
if (!(0, node_fs_1.existsSync)(root)) {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
return (0, node_fs_1.readdirSync)(root)
|
|
625
|
+
.filter((name) => name.trim().length > 0)
|
|
626
|
+
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))
|
|
627
|
+
.map((name) => node_path_1.default.join(root, name));
|
|
628
|
+
}
|
|
629
|
+
function hasPlaydropPluginReferences(pluginRoot) {
|
|
630
|
+
return PLAYDROP_PLUGIN_REFERENCE_FILES.every((relativePath) => (0, node_fs_1.existsSync)(node_path_1.default.join(pluginRoot, relativePath)));
|
|
631
|
+
}
|
|
632
|
+
function resolvePlaydropPluginRoot(input = {}) {
|
|
633
|
+
const homeDir = input.homeDir ?? node_os_1.default.homedir();
|
|
634
|
+
const env = input.env ?? node_process_1.default.env;
|
|
635
|
+
const explicitRoot = env.PLAYDROP_WORKER_PLAYDROP_PLUGIN_DIR?.trim();
|
|
636
|
+
if (explicitRoot) {
|
|
637
|
+
const resolved = node_path_1.default.resolve(explicitRoot);
|
|
638
|
+
if (!hasPlaydropPluginReferences(resolved)) {
|
|
639
|
+
throw new Error(`playdrop_plugin_reference_missing: expected PlayDrop plugin skills and scripts under ${resolved}`);
|
|
640
|
+
}
|
|
641
|
+
return resolved;
|
|
642
|
+
}
|
|
643
|
+
const candidates = [
|
|
644
|
+
node_path_1.default.join(homeDir, 'Documents', 'playdrop-plugin'),
|
|
645
|
+
node_path_1.default.join(homeDir, '.claude', 'plugins', 'marketplaces', 'playdrop'),
|
|
646
|
+
...candidateVersionDirectories(node_path_1.default.join(homeDir, '.claude', 'plugins', 'cache', 'playdrop', 'playdrop')),
|
|
647
|
+
node_path_1.default.join(homeDir, '.codex', 'plugins', 'playdrop'),
|
|
648
|
+
node_path_1.default.join(homeDir, '.codex', 'plugins', 'cache', 'playdrop', 'playdrop'),
|
|
649
|
+
...candidateVersionDirectories(node_path_1.default.join(homeDir, '.codex', 'plugins', 'cache', 'playdrop', 'playdrop')),
|
|
650
|
+
...candidateVersionDirectories(node_path_1.default.join(homeDir, '.codex', 'plugins', 'cache', 'olivier-local-plugins', 'playdrop')),
|
|
651
|
+
];
|
|
652
|
+
const pluginRoot = candidates.find(hasPlaydropPluginReferences);
|
|
653
|
+
if (!pluginRoot) {
|
|
654
|
+
throw new Error('playdrop_plugin_reference_missing: install or update the PlayDrop plugin so the required skills and scripts are available.');
|
|
655
|
+
}
|
|
656
|
+
return pluginRoot;
|
|
657
|
+
}
|
|
658
|
+
async function stagePlaydropPluginReferences(input) {
|
|
659
|
+
const pluginRoot = node_path_1.default.resolve(input.pluginRoot);
|
|
660
|
+
if (!hasPlaydropPluginReferences(pluginRoot)) {
|
|
661
|
+
throw new Error(`playdrop_plugin_reference_missing: expected required PlayDrop plugin skills and scripts under ${pluginRoot}`);
|
|
662
|
+
}
|
|
663
|
+
let referenceFiles;
|
|
664
|
+
if (input.kind === 'GAME_REVIEW') {
|
|
665
|
+
referenceFiles = PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES;
|
|
666
|
+
}
|
|
667
|
+
else if (input.kind === 'NEW_GAME' || input.kind === 'GAME_UPDATE') {
|
|
668
|
+
referenceFiles = PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES;
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
throw new Error(`unsupported_agent_task_kind:${input.kind}`);
|
|
672
|
+
}
|
|
673
|
+
const staged = [];
|
|
674
|
+
for (const relativePath of referenceFiles) {
|
|
675
|
+
const destinationRelativePath = node_path_1.default.join(STAGED_PLAYDROP_PLUGIN_ROOT, relativePath);
|
|
676
|
+
const destination = resolveWorkspaceFileDestination(input.workspaceDir, destinationRelativePath);
|
|
677
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
|
|
678
|
+
await (0, promises_1.copyFile)(node_path_1.default.join(pluginRoot, relativePath), destination);
|
|
679
|
+
staged.push(destinationRelativePath.replace(/\\/g, '/'));
|
|
680
|
+
}
|
|
681
|
+
return staged;
|
|
682
|
+
}
|
|
683
|
+
// A PlayDrop project root is the topmost directory holding a catalogue.json,
|
|
684
|
+
// the same marker "playdrop project publish" resolves its target from. Nested
|
|
685
|
+
// catalogue.json files belong to the root above them, so the walk stops at the
|
|
686
|
+
// first catalogue.json on each branch.
|
|
687
|
+
function discoverWorkerProjectRoot(workspaceDir) {
|
|
688
|
+
const roots = [];
|
|
689
|
+
const walk = (dir) => {
|
|
690
|
+
if ((0, node_fs_1.existsSync)(node_path_1.default.join(dir, 'catalogue.json'))) {
|
|
691
|
+
roots.push(dir);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
695
|
+
if (!entry.isDirectory()) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
walk(node_path_1.default.join(dir, entry.name));
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
walk(workspaceDir);
|
|
705
|
+
if (roots.length === 0) {
|
|
706
|
+
throw new Error(`agent_task_no_project: no PlayDrop project with a catalogue.json was found under ${workspaceDir}.`);
|
|
707
|
+
}
|
|
708
|
+
if (roots.length > 1) {
|
|
709
|
+
throw new Error(`agent_task_multiple_projects: found ${roots.length} PlayDrop project roots under ${workspaceDir} (${roots.join(', ')}).`);
|
|
710
|
+
}
|
|
711
|
+
return roots[0];
|
|
712
|
+
}
|
|
713
|
+
function writeWorkerTaskState(state) {
|
|
714
|
+
const file = workerStateFilePath();
|
|
715
|
+
(0, node_fs_1.mkdirSync)(node_path_1.default.dirname(file), { recursive: true });
|
|
716
|
+
(0, node_fs_1.writeFileSync)(file, JSON.stringify(state, null, 2));
|
|
717
|
+
}
|
|
718
|
+
function readWorkerTaskState() {
|
|
719
|
+
const file = workerStateFilePath();
|
|
720
|
+
let raw;
|
|
721
|
+
try {
|
|
722
|
+
raw = (0, node_fs_1.readFileSync)(file, 'utf8');
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const parsed = JSON.parse(raw);
|
|
728
|
+
if (!parsed || !Number.isInteger(parsed.taskId) || parsed.taskId <= 0) {
|
|
729
|
+
throw new Error(`invalid_worker_state_file:${file}`);
|
|
730
|
+
}
|
|
731
|
+
return parsed;
|
|
732
|
+
}
|
|
733
|
+
function clearWorkerTaskState() {
|
|
734
|
+
(0, node_fs_1.rmSync)(workerStateFilePath(), { force: true });
|
|
735
|
+
}
|
|
736
|
+
function isWorkerSlackAlertEnabled() {
|
|
737
|
+
const normalized = node_process_1.default.env.PLAYDROP_WORKER_SLACK_ALERTS?.trim().toLowerCase();
|
|
738
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
|
739
|
+
}
|
|
740
|
+
function buildWorkerHealthAlertText(input) {
|
|
741
|
+
return [
|
|
742
|
+
`PlayDrop Worker ${input.state}`,
|
|
743
|
+
`env=${input.env}`,
|
|
744
|
+
`worker=${input.workerName}`,
|
|
745
|
+
input.taskId ? `task=${input.taskId}` : null,
|
|
746
|
+
input.detail ? `detail=${input.detail}` : null,
|
|
747
|
+
].filter(Boolean).join(' ');
|
|
748
|
+
}
|
|
749
|
+
async function sendWorkerHealthAlert(input) {
|
|
750
|
+
if (!isWorkerSlackAlertEnabled()) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const token = node_process_1.default.env.PLAYDROP_SLACK_BOT_TOKEN?.trim();
|
|
754
|
+
if (!token) {
|
|
755
|
+
throw new Error('missing_playdrop_slack_bot_token');
|
|
756
|
+
}
|
|
757
|
+
const channelId = node_process_1.default.env.PLAYDROP_WORKER_SLACK_CHANNEL_ID?.trim();
|
|
758
|
+
if (!channelId) {
|
|
759
|
+
throw new Error('missing_playdrop_worker_slack_channel_id');
|
|
760
|
+
}
|
|
761
|
+
const response = await fetch(`${SLACK_API_BASE_URL}/chat.postMessage`, {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: {
|
|
764
|
+
authorization: `Bearer ${token}`,
|
|
765
|
+
'content-type': 'application/json; charset=utf-8',
|
|
766
|
+
},
|
|
767
|
+
body: JSON.stringify({
|
|
768
|
+
channel: channelId,
|
|
769
|
+
text: buildWorkerHealthAlertText(input),
|
|
770
|
+
}),
|
|
771
|
+
});
|
|
772
|
+
const raw = await response.text();
|
|
773
|
+
const payload = raw ? JSON.parse(raw) : {};
|
|
774
|
+
if (!response.ok) {
|
|
775
|
+
throw new Error(`slack_http_error:${response.status}`);
|
|
776
|
+
}
|
|
777
|
+
if (payload.ok !== true) {
|
|
778
|
+
throw new Error(`slack_api_error:${String(payload.error || 'unknown')}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function sendWorkerHealthAlertSafely(input) {
|
|
782
|
+
try {
|
|
783
|
+
await sendWorkerHealthAlert(input);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
console.error(`Worker health alert failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
function parsePositiveInteger(value, code) {
|
|
790
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
791
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
792
|
+
throw new Error(code);
|
|
793
|
+
}
|
|
794
|
+
return parsed;
|
|
795
|
+
}
|
|
796
|
+
function parseOptionalPct(value) {
|
|
797
|
+
if (value === undefined || value === null || value === '') {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value), 10);
|
|
801
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
|
|
802
|
+
throw new Error('invalid_task_pct');
|
|
803
|
+
}
|
|
804
|
+
return parsed;
|
|
805
|
+
}
|
|
806
|
+
function normalizeTaskEventKind(value) {
|
|
807
|
+
const normalized = value?.trim().toLowerCase().replace(/[\s-]+/g, '_') || 'progress';
|
|
808
|
+
if (normalized === 'progress') {
|
|
809
|
+
return normalized;
|
|
810
|
+
}
|
|
811
|
+
throw new Error('invalid_task_event_kind');
|
|
812
|
+
}
|
|
813
|
+
function resolveWorkerEventQueueDir() {
|
|
814
|
+
if (node_process_1.default.env.PLAYDROP_WORKER_CONTEXT !== '1') {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
const eventDir = node_process_1.default.env.PLAYDROP_WORKER_EVENT_DIR?.trim();
|
|
818
|
+
return eventDir || null;
|
|
819
|
+
}
|
|
820
|
+
function enqueueWorkerContextEvent(event) {
|
|
821
|
+
const eventDir = resolveWorkerEventQueueDir();
|
|
822
|
+
if (!eventDir) {
|
|
823
|
+
throw new Error('task_context_event_queue_missing');
|
|
824
|
+
}
|
|
825
|
+
(0, node_fs_1.mkdirSync)(eventDir, { recursive: true });
|
|
826
|
+
const eventId = `${Date.now()}-${crypto.randomUUID()}`;
|
|
827
|
+
const tmpPath = node_path_1.default.join(eventDir, `${eventId}.tmp`);
|
|
828
|
+
const finalPath = node_path_1.default.join(eventDir, `${eventId}.json`);
|
|
829
|
+
(0, node_fs_1.writeFileSync)(tmpPath, JSON.stringify(event), 'utf8');
|
|
830
|
+
(0, node_fs_1.renameSync)(tmpPath, finalPath);
|
|
831
|
+
}
|
|
832
|
+
function resolveWorkerTaskStateForWrite() {
|
|
833
|
+
const state = readWorkerTaskState();
|
|
834
|
+
if (!state) {
|
|
835
|
+
throw new Error('task_context_missing');
|
|
836
|
+
}
|
|
837
|
+
throw new Error('task_context_supervisor_only');
|
|
838
|
+
}
|
|
839
|
+
function isLeaseInvalidError(error) {
|
|
840
|
+
if (error instanceof types_1.ApiError && error.code === 'agent_task_lease_invalid') {
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
return error instanceof Error && error.message.includes('agent_task_lease_invalid');
|
|
844
|
+
}
|
|
845
|
+
function isAgentTaskNotRunningError(error) {
|
|
846
|
+
if (error instanceof types_1.ApiError && error.code === 'agent_task_not_running') {
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
return error instanceof Error && error.message.includes('agent_task_not_running');
|
|
850
|
+
}
|
|
851
|
+
// A 401 from the API means the stored session is expired or revoked. Backoff
|
|
852
|
+
// can never fix that, so the worker must stop with a visible re-login message
|
|
853
|
+
// instead of retrying forever (spec section 9, session longevity).
|
|
854
|
+
function isWorkerAuthFailureError(error) {
|
|
855
|
+
return error instanceof types_1.ApiError && error.status === 401;
|
|
856
|
+
}
|
|
857
|
+
function classifyWorkerEventDrainError(error) {
|
|
858
|
+
if (isWorkerAuthFailureError(error)) {
|
|
859
|
+
return 'session_expired';
|
|
860
|
+
}
|
|
861
|
+
if (isLeaseInvalidError(error)) {
|
|
862
|
+
return 'lease_invalid';
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
// Throws for claim errors that must stop the worker; returns for transient
|
|
867
|
+
// errors the claim loop may retry with backoff.
|
|
868
|
+
function assertWorkerClaimErrorRetryable(error) {
|
|
869
|
+
if (isWorkerAuthFailureError(error)) {
|
|
870
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
871
|
+
}
|
|
872
|
+
if (error instanceof types_1.ApiError && error.status === 403) {
|
|
873
|
+
throw new Error(`worker_claim_forbidden: the server rejected this worker session (${error.message}).`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function resolvePlaydropAssetRequirementFromText(value) {
|
|
877
|
+
const text = typeof value === 'string' ? value.toLowerCase() : '';
|
|
878
|
+
if (!text.trim()) {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
const mentionsPlaydrop = /\bplay\s*drop\b|\bplaydrop\b/.test(text);
|
|
882
|
+
if (!mentionsPlaydrop) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
if (/\basset\s*pack(s)?\b|\bpack(s)?\b/.test(text)) {
|
|
886
|
+
return 'PACK';
|
|
887
|
+
}
|
|
888
|
+
if (/\basset(s)?\b|\bsprite(s)?\b|\bmodel(s)?\b|\bart\b|\bcharacter(s)?\b|\bprop(s)?\b/.test(text)) {
|
|
889
|
+
return 'ASSET_OR_PACK';
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
function readCodexAuthenticated(homeDir = node_os_1.default.homedir()) {
|
|
894
|
+
try {
|
|
895
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(homeDir, '.codex', 'auth.json'), 'utf8'));
|
|
896
|
+
const tokens = parsed.tokens;
|
|
897
|
+
return Boolean(tokens?.id_token || parsed.auth_mode === 'apikey');
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
function probeNpmVersion() {
|
|
904
|
+
const result = (0, shellProbe_1.runShell)('npm --version');
|
|
905
|
+
const version = result.output.trim().split('\n')[0]?.trim() ?? '';
|
|
906
|
+
if (result.exitCode !== 0 || !version) {
|
|
907
|
+
throw new Error(`worker_preflight_npm_missing: "npm --version" exited with code ${result.exitCode}.`);
|
|
908
|
+
}
|
|
909
|
+
return version;
|
|
910
|
+
}
|
|
911
|
+
function probePlaywrightChromium() {
|
|
912
|
+
let playwrightCore;
|
|
913
|
+
let packageJson;
|
|
914
|
+
try {
|
|
915
|
+
playwrightCore = requireFromWorker('playwright-core');
|
|
916
|
+
packageJson = requireFromWorker('playwright-core/package.json');
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
throw new Error('worker_preflight_playwright_missing: install Playwright before starting the worker.');
|
|
920
|
+
}
|
|
921
|
+
const executablePath = playwrightCore.chromium.executablePath();
|
|
922
|
+
const version = typeof packageJson.version === 'string' && packageJson.version.trim() ? packageJson.version.trim() : 'unknown';
|
|
923
|
+
const chromiumInstalled = (0, node_fs_1.existsSync)(executablePath);
|
|
924
|
+
if (!chromiumInstalled) {
|
|
925
|
+
throw new Error(`worker_preflight_chromium_missing: install Playwright Chromium before starting the worker (${executablePath}).`);
|
|
926
|
+
}
|
|
927
|
+
return { version, chromiumInstalled };
|
|
928
|
+
}
|
|
929
|
+
function readWorkerModelList(agent, envName) {
|
|
930
|
+
const raw = node_process_1.default.env[envName];
|
|
931
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
932
|
+
return [...types_1.AGENT_TASK_MODEL_VALUES_BY_AGENT[agent]];
|
|
933
|
+
}
|
|
934
|
+
const values = raw
|
|
935
|
+
.split(',')
|
|
936
|
+
.map((entry) => entry.trim())
|
|
937
|
+
.filter(Boolean)
|
|
938
|
+
.map((entry) => (0, types_1.normalizeAgentTaskModel)(agent, entry));
|
|
939
|
+
const unsupported = raw
|
|
940
|
+
.split(',')
|
|
941
|
+
.map((entry) => entry.trim())
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.filter((entry) => !(0, types_1.normalizeAgentTaskModel)(agent, entry));
|
|
944
|
+
if (unsupported.length > 0) {
|
|
945
|
+
throw new Error(`worker_preflight_unsupported_model_list:${envName}:${unsupported.join(',')}`);
|
|
946
|
+
}
|
|
947
|
+
const unique = Array.from(new Set(values));
|
|
948
|
+
if (unique.length === 0 || unique.some((model) => !model || model.length > 120)) {
|
|
949
|
+
throw new Error(`worker_preflight_invalid_model_list:${envName}`);
|
|
950
|
+
}
|
|
951
|
+
return unique;
|
|
952
|
+
}
|
|
953
|
+
function parseEnvLine(line) {
|
|
954
|
+
const trimmed = line.trim();
|
|
955
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trim() : trimmed;
|
|
959
|
+
const separator = withoutExport.indexOf('=');
|
|
960
|
+
if (separator <= 0) {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
const key = withoutExport.slice(0, separator).trim();
|
|
964
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
let value = withoutExport.slice(separator + 1).trim();
|
|
968
|
+
if ((value.startsWith('"') && value.endsWith('"'))
|
|
969
|
+
|| (value.startsWith("'") && value.endsWith("'"))) {
|
|
970
|
+
value = value.slice(1, -1);
|
|
971
|
+
}
|
|
972
|
+
return { key, value };
|
|
973
|
+
}
|
|
974
|
+
function loadWorkerEnvFile(envName, cwd = node_process_1.default.cwd()) {
|
|
975
|
+
const normalizedEnv = envName?.trim();
|
|
976
|
+
if (!normalizedEnv || normalizedEnv === 'prod') {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
if (!/^[A-Za-z0-9_-]+$/.test(normalizedEnv)) {
|
|
980
|
+
throw new Error(`invalid_worker_env:${normalizedEnv}`);
|
|
981
|
+
}
|
|
982
|
+
const envPath = node_path_1.default.join(cwd, 'infra', `.env.${normalizedEnv}`);
|
|
983
|
+
if (!(0, node_fs_1.existsSync)(envPath)) {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const contents = (0, node_fs_1.readFileSync)(envPath, 'utf8');
|
|
987
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
988
|
+
const entry = parseEnvLine(line);
|
|
989
|
+
if (!entry || node_process_1.default.env[entry.key] !== undefined) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
node_process_1.default.env[entry.key] = entry.value;
|
|
993
|
+
}
|
|
994
|
+
return envPath;
|
|
995
|
+
}
|
|
996
|
+
function buildWorkerCapabilities(input) {
|
|
997
|
+
const codexVersion = typeof input === 'string' ? input : input.codexVersion?.trim() || null;
|
|
998
|
+
const codexAuthenticated = typeof input === 'string' ? true : input.codexAuthenticated === true;
|
|
999
|
+
const claudeVersion = typeof input === 'string' ? null : input.claudeVersion?.trim() || null;
|
|
1000
|
+
const claudeAuthenticated = typeof input === 'string' ? false : input.claudeAuthenticated === true;
|
|
1001
|
+
const npmVersion = typeof input === 'string' ? null : input.npmVersion?.trim() || null;
|
|
1002
|
+
const playwright = typeof input === 'string' ? null : input.playwright ?? null;
|
|
1003
|
+
const maxParallelTasks = typeof input === 'string'
|
|
1004
|
+
? DEFAULT_WORKER_MAX_PARALLEL_TASKS
|
|
1005
|
+
: input.maxParallelTasks ?? DEFAULT_WORKER_MAX_PARALLEL_TASKS;
|
|
1006
|
+
const runningTaskCount = typeof input === 'string' ? 0 : input.runningTaskCount ?? 0;
|
|
1007
|
+
const degradedReasons = [];
|
|
1008
|
+
const agents = [];
|
|
1009
|
+
if (codexVersion) {
|
|
1010
|
+
if (!codexAuthenticated) {
|
|
1011
|
+
degradedReasons.push('codex_not_authenticated');
|
|
1012
|
+
}
|
|
1013
|
+
agents.push({
|
|
1014
|
+
agent: 'CODEX',
|
|
1015
|
+
cliVersion: codexVersion,
|
|
1016
|
+
authenticated: codexAuthenticated,
|
|
1017
|
+
models: readWorkerModelList('CODEX', 'PLAYDROP_WORKER_CODEX_MODELS'),
|
|
1018
|
+
ready: codexAuthenticated,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
if (claudeVersion) {
|
|
1022
|
+
if (!claudeAuthenticated) {
|
|
1023
|
+
degradedReasons.push('claude_not_authenticated');
|
|
1024
|
+
}
|
|
1025
|
+
agents.push({
|
|
1026
|
+
agent: 'CLAUDE_CODE',
|
|
1027
|
+
cliVersion: claudeVersion,
|
|
1028
|
+
authenticated: claudeAuthenticated,
|
|
1029
|
+
models: readWorkerModelList('CLAUDE_CODE', 'PLAYDROP_WORKER_CLAUDE_MODELS'),
|
|
1030
|
+
ready: claudeAuthenticated,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
if (agents.length === 0) {
|
|
1034
|
+
throw new Error('agent_cli_not_found: install and authenticate Codex or Claude Code before starting a PlayDrop worker.');
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
agent: agents[0].agent,
|
|
1038
|
+
supportedKinds: [...WORKER_SUPPORTED_KINDS],
|
|
1039
|
+
maxParallelTasks,
|
|
1040
|
+
runningTaskCount,
|
|
1041
|
+
cliVersion: (0, clientInfo_1.getCliVersion)(),
|
|
1042
|
+
nodeVersion: node_process_1.default.versions.node,
|
|
1043
|
+
agents,
|
|
1044
|
+
ready: agents.some((agent) => agent.ready === true),
|
|
1045
|
+
degradedReasons,
|
|
1046
|
+
...(npmVersion ? { npmVersion } : {}),
|
|
1047
|
+
...(playwright ? { playwright } : {}),
|
|
1048
|
+
os: node_process_1.default.platform,
|
|
1049
|
+
arch: node_process_1.default.arch,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function buildWorkerClaimBody(input) {
|
|
1053
|
+
return {
|
|
1054
|
+
workerKey: input.workerKey,
|
|
1055
|
+
capabilities: {
|
|
1056
|
+
...input.capabilities,
|
|
1057
|
+
runningTaskCount: input.runningTaskCount ?? input.capabilities.runningTaskCount ?? 0,
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function resolveWorkerBaseSource(value) {
|
|
1062
|
+
if (!value) {
|
|
1063
|
+
throw new Error('agent_task_assignment_missing_base_source');
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof value.appName !== 'string' || !value.appName.trim()) {
|
|
1066
|
+
throw new Error('agent_task_base_app_name_missing');
|
|
1067
|
+
}
|
|
1068
|
+
if (typeof value.version !== 'string' || !value.version.trim()) {
|
|
1069
|
+
throw new Error('agent_task_base_app_version_missing');
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof value.appId !== 'number' || !Number.isInteger(value.appId) || value.appId <= 0) {
|
|
1072
|
+
throw new Error('agent_task_base_app_id_missing');
|
|
1073
|
+
}
|
|
1074
|
+
if (typeof value.appVersionId !== 'number' || !Number.isInteger(value.appVersionId) || value.appVersionId <= 0) {
|
|
1075
|
+
throw new Error('agent_task_base_app_version_id_missing');
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
appName: value.appName.trim(),
|
|
1079
|
+
version: value.version.trim(),
|
|
1080
|
+
appId: value.appId,
|
|
1081
|
+
appVersionId: value.appVersionId,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
// Extracts a zip archive into targetDir using fflate (already a CLI
|
|
1085
|
+
// dependency; see src/commands/create.ts). Entries that resolve outside the
|
|
1086
|
+
// target directory throw instead of being skipped: a base source archive with
|
|
1087
|
+
// traversal entries is corrupt and must fail the task loudly.
|
|
1088
|
+
function extractZipArchive(zipBuffer, targetDir) {
|
|
1089
|
+
const files = (0, fflate_1.unzipSync)(zipBuffer);
|
|
1090
|
+
const names = Object.keys(files);
|
|
1091
|
+
if (names.length === 0) {
|
|
1092
|
+
throw new Error('agent_task_base_source_empty: the downloaded source archive contained no files.');
|
|
1093
|
+
}
|
|
1094
|
+
const resolvedTarget = node_path_1.default.resolve(targetDir);
|
|
1095
|
+
const written = [];
|
|
1096
|
+
for (const originalName of names) {
|
|
1097
|
+
const name = originalName.replace(/\\/g, '/');
|
|
1098
|
+
const destination = node_path_1.default.resolve(resolvedTarget, name);
|
|
1099
|
+
if (destination !== resolvedTarget && !destination.startsWith(resolvedTarget + node_path_1.default.sep)) {
|
|
1100
|
+
throw new Error(`agent_task_base_source_unsafe_entry: archive entry "${originalName}" escapes the staging directory.`);
|
|
1101
|
+
}
|
|
1102
|
+
if (name.endsWith('/')) {
|
|
1103
|
+
(0, node_fs_1.mkdirSync)(destination, { recursive: true });
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
(0, node_fs_1.mkdirSync)(node_path_1.default.dirname(destination), { recursive: true });
|
|
1107
|
+
(0, node_fs_1.writeFileSync)(destination, Buffer.from(files[originalName]));
|
|
1108
|
+
written.push(name);
|
|
1109
|
+
}
|
|
1110
|
+
return written;
|
|
1111
|
+
}
|
|
1112
|
+
// Reads the version string of the named app from the catalogue.json at the
|
|
1113
|
+
// project root. Used for the staged base version and the version-bump guard.
|
|
1114
|
+
function readWorkerProjectVersion(projectDir, appName) {
|
|
1115
|
+
const cataloguePath = node_path_1.default.join(projectDir, 'catalogue.json');
|
|
1116
|
+
let raw;
|
|
1117
|
+
try {
|
|
1118
|
+
raw = (0, node_fs_1.readFileSync)(cataloguePath, 'utf8');
|
|
1119
|
+
}
|
|
1120
|
+
catch (error) {
|
|
1121
|
+
throw new Error(`agent_task_catalogue_unreadable: could not read ${cataloguePath} (${error instanceof Error ? error.message : String(error)}).`);
|
|
1122
|
+
}
|
|
1123
|
+
let parsed;
|
|
1124
|
+
try {
|
|
1125
|
+
parsed = JSON.parse(raw);
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
throw new Error(`agent_task_catalogue_invalid_json: ${cataloguePath} is not valid JSON.`);
|
|
1129
|
+
}
|
|
1130
|
+
const apps = parsed?.apps;
|
|
1131
|
+
if (!Array.isArray(apps)) {
|
|
1132
|
+
throw new Error(`agent_task_catalogue_apps_missing: ${cataloguePath} has no "apps" array.`);
|
|
1133
|
+
}
|
|
1134
|
+
const entry = apps.find((candidate) => Boolean(candidate) && typeof candidate === 'object' && candidate.name === appName);
|
|
1135
|
+
if (!entry) {
|
|
1136
|
+
throw new Error(`agent_task_catalogue_app_missing: no app named "${appName}" in ${cataloguePath}.`);
|
|
1137
|
+
}
|
|
1138
|
+
const version = typeof entry.version === 'string' ? entry.version.trim() : '';
|
|
1139
|
+
if (!version) {
|
|
1140
|
+
throw new Error(`agent_task_catalogue_version_missing: app "${appName}" in ${cataloguePath} has no version string.`);
|
|
1141
|
+
}
|
|
1142
|
+
return version;
|
|
1143
|
+
}
|
|
1144
|
+
function normalizePreviewString(value) {
|
|
1145
|
+
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
1146
|
+
return normalized || undefined;
|
|
1147
|
+
}
|
|
1148
|
+
function normalizePreviewStringArray(value) {
|
|
1149
|
+
if (!Array.isArray(value)) {
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
const values = value
|
|
1153
|
+
.map((entry) => normalizePreviewString(entry))
|
|
1154
|
+
.filter((entry) => Boolean(entry));
|
|
1155
|
+
return values.length > 0 ? values : undefined;
|
|
1156
|
+
}
|
|
1157
|
+
function normalizePreviewSurfaceTargets(value) {
|
|
1158
|
+
if (Array.isArray(value)) {
|
|
1159
|
+
return normalizePreviewStringArray(value);
|
|
1160
|
+
}
|
|
1161
|
+
if (!value || typeof value !== 'object') {
|
|
1162
|
+
return undefined;
|
|
1163
|
+
}
|
|
1164
|
+
const targets = Object.entries(value)
|
|
1165
|
+
.filter(([, enabled]) => enabled === true)
|
|
1166
|
+
.map(([target]) => target.trim())
|
|
1167
|
+
.filter(Boolean);
|
|
1168
|
+
return targets.length > 0 ? targets : undefined;
|
|
1169
|
+
}
|
|
1170
|
+
function countWorkspaceFiles(workspaceDir) {
|
|
1171
|
+
let count = 0;
|
|
1172
|
+
const walk = (dir) => {
|
|
1173
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
1174
|
+
if (entry.name === 'node_modules' || entry.name === '.git') {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const entryPath = node_path_1.default.join(dir, entry.name);
|
|
1178
|
+
if (entry.isDirectory()) {
|
|
1179
|
+
walk(entryPath);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (entry.isFile()) {
|
|
1183
|
+
count += 1;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
walk(workspaceDir);
|
|
1188
|
+
return count;
|
|
1189
|
+
}
|
|
1190
|
+
function buildCataloguePreviewPayload(cataloguePath, workspaceDir = node_path_1.default.dirname(cataloguePath)) {
|
|
1191
|
+
let parsed;
|
|
1192
|
+
try {
|
|
1193
|
+
parsed = JSON.parse((0, node_fs_1.readFileSync)(cataloguePath, 'utf8'));
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
throw new Error(`agent_task_catalogue_preview_unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1197
|
+
}
|
|
1198
|
+
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
1199
|
+
? parsed
|
|
1200
|
+
: null;
|
|
1201
|
+
if (!root) {
|
|
1202
|
+
throw new Error('agent_task_catalogue_preview_invalid');
|
|
1203
|
+
}
|
|
1204
|
+
const app = Array.isArray(root.apps)
|
|
1205
|
+
? root.apps.find((candidate) => Boolean(candidate) && typeof candidate === 'object' && !Array.isArray(candidate))
|
|
1206
|
+
: root;
|
|
1207
|
+
if (!app) {
|
|
1208
|
+
throw new Error('agent_task_catalogue_preview_app_missing');
|
|
1209
|
+
}
|
|
1210
|
+
const catalogue = {};
|
|
1211
|
+
const appName = normalizePreviewString(app.name ?? app.appName);
|
|
1212
|
+
if (appName)
|
|
1213
|
+
catalogue.appName = appName;
|
|
1214
|
+
const displayName = normalizePreviewString(app.displayName);
|
|
1215
|
+
if (displayName)
|
|
1216
|
+
catalogue.displayName = displayName;
|
|
1217
|
+
const description = normalizePreviewString(app.description);
|
|
1218
|
+
if (description)
|
|
1219
|
+
catalogue.description = description;
|
|
1220
|
+
const emoji = normalizePreviewString(app.emoji);
|
|
1221
|
+
if (emoji)
|
|
1222
|
+
catalogue.emoji = emoji;
|
|
1223
|
+
const color = normalizePreviewString(app.color);
|
|
1224
|
+
if (color)
|
|
1225
|
+
catalogue.color = color;
|
|
1226
|
+
const version = normalizePreviewString(app.version);
|
|
1227
|
+
if (version)
|
|
1228
|
+
catalogue.version = version;
|
|
1229
|
+
const releaseNotes = normalizePreviewString(app.releaseNotes);
|
|
1230
|
+
if (releaseNotes)
|
|
1231
|
+
catalogue.releaseNotes = releaseNotes;
|
|
1232
|
+
const listing = app.listing && typeof app.listing === 'object' && !Array.isArray(app.listing)
|
|
1233
|
+
? app.listing
|
|
1234
|
+
: {};
|
|
1235
|
+
const heroPortraitPath = normalizePreviewString(listing.heroPortrait ?? listing.heroPortraitPath ?? app.heroPortraitPath);
|
|
1236
|
+
if (heroPortraitPath)
|
|
1237
|
+
catalogue.heroPortraitPath = heroPortraitPath;
|
|
1238
|
+
const heroLandscapePath = normalizePreviewString(listing.heroLandscape ?? listing.heroLandscapePath ?? app.heroLandscapePath);
|
|
1239
|
+
if (heroLandscapePath)
|
|
1240
|
+
catalogue.heroLandscapePath = heroLandscapePath;
|
|
1241
|
+
const surfaceTargets = normalizePreviewSurfaceTargets(app.surfaceTargets);
|
|
1242
|
+
if (surfaceTargets)
|
|
1243
|
+
catalogue.surfaceTargets = surfaceTargets;
|
|
1244
|
+
const tags = normalizePreviewStringArray(app.tags);
|
|
1245
|
+
if (tags)
|
|
1246
|
+
catalogue.tags = tags;
|
|
1247
|
+
const workspace = {
|
|
1248
|
+
filesChanged: countWorkspaceFiles(workspaceDir),
|
|
1249
|
+
};
|
|
1250
|
+
const entryPoint = normalizePreviewString(app.entryPoint ?? app.file);
|
|
1251
|
+
if (entryPoint) {
|
|
1252
|
+
workspace.entryPoint = entryPoint;
|
|
1253
|
+
}
|
|
1254
|
+
return { catalogue, workspace };
|
|
1255
|
+
}
|
|
1256
|
+
function normalizeQueuedWorkerEvent(value) {
|
|
1257
|
+
const object = value && typeof value === 'object' && !Array.isArray(value)
|
|
1258
|
+
? value
|
|
1259
|
+
: null;
|
|
1260
|
+
if (!object) {
|
|
1261
|
+
throw new Error('agent_task_worker_event_invalid');
|
|
1262
|
+
}
|
|
1263
|
+
const kind = object.kind === 'progress' || object.kind === 'catalogue_preview'
|
|
1264
|
+
? object.kind
|
|
1265
|
+
: null;
|
|
1266
|
+
if (!kind) {
|
|
1267
|
+
throw new Error('agent_task_worker_event_kind_invalid');
|
|
1268
|
+
}
|
|
1269
|
+
const message = typeof object.message === 'string' ? object.message.trim() : '';
|
|
1270
|
+
if (!message) {
|
|
1271
|
+
throw new Error('agent_task_worker_event_message_missing');
|
|
1272
|
+
}
|
|
1273
|
+
const phase = typeof object.phase === 'string' && object.phase.trim()
|
|
1274
|
+
? object.phase.trim()
|
|
1275
|
+
: null;
|
|
1276
|
+
const pct = object.pct === undefined || object.pct === null
|
|
1277
|
+
? null
|
|
1278
|
+
: parseOptionalPct(object.pct);
|
|
1279
|
+
const payload = object.payload && typeof object.payload === 'object' && !Array.isArray(object.payload)
|
|
1280
|
+
? object.payload
|
|
1281
|
+
: undefined;
|
|
1282
|
+
if (kind === 'catalogue_preview' && !payload) {
|
|
1283
|
+
throw new Error('agent_task_worker_event_payload_missing');
|
|
1284
|
+
}
|
|
1285
|
+
if (kind === 'progress') {
|
|
1286
|
+
return { kind, phase, message, pct };
|
|
1287
|
+
}
|
|
1288
|
+
return { kind, phase, message, pct, payload };
|
|
1289
|
+
}
|
|
1290
|
+
async function drainWorkerEventQueue(input) {
|
|
1291
|
+
if (!(0, node_fs_1.existsSync)(input.eventDir)) {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const entries = (0, node_fs_1.readdirSync)(input.eventDir)
|
|
1295
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
1296
|
+
.sort();
|
|
1297
|
+
for (const entry of entries) {
|
|
1298
|
+
const filePath = node_path_1.default.join(input.eventDir, entry);
|
|
1299
|
+
const raw = (0, node_fs_1.readFileSync)(filePath, 'utf8');
|
|
1300
|
+
const event = normalizeQueuedWorkerEvent(JSON.parse(raw));
|
|
1301
|
+
await input.client.workerCreateAgentTaskEvent(input.taskId, {
|
|
1302
|
+
workerKey: input.workerKey,
|
|
1303
|
+
leaseToken: input.leaseToken,
|
|
1304
|
+
kind: event.kind,
|
|
1305
|
+
phase: event.phase,
|
|
1306
|
+
message: event.message,
|
|
1307
|
+
pct: event.pct,
|
|
1308
|
+
...(event.payload ? { payload: event.payload } : {}),
|
|
1309
|
+
});
|
|
1310
|
+
(0, node_fs_1.rmSync)(filePath, { force: true });
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Pre-publish guard for GAME_UPDATE tasks: the server rejects a duplicate
|
|
1314
|
+
// version anyway, but failing here gives the task a precise error code.
|
|
1315
|
+
function assertWorkerProjectVersionBumped(input) {
|
|
1316
|
+
if (input.projectVersion === input.baseVersion) {
|
|
1317
|
+
throw new Error(`agent_task_version_not_bumped: app "${input.appName}" version is still "${input.baseVersion}"; the update must bump the catalogue.json version before publish.`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// Downloads the base app source archive and unzips it into the workspace so
|
|
1321
|
+
// the project root (catalogue.json) lands inside the workspace before Codex
|
|
1322
|
+
// starts. Returns the staged project root and its catalogue version string.
|
|
1323
|
+
async function stageWorkerBaseAppSource(input) {
|
|
1324
|
+
const { blob } = await input.client.downloadAppSource(input.creatorUsername, input.baseApp.appName, input.baseApp.version);
|
|
1325
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
1326
|
+
const stagingDir = node_path_1.default.join(input.workspaceDir, input.baseApp.appName);
|
|
1327
|
+
(0, node_fs_1.mkdirSync)(stagingDir, { recursive: true });
|
|
1328
|
+
extractZipArchive(new Uint8Array(buffer), stagingDir);
|
|
1329
|
+
const stagedProjectDir = discoverWorkerProjectRoot(stagingDir);
|
|
1330
|
+
const stagedBaseVersion = readWorkerProjectVersion(stagedProjectDir, input.baseApp.appName);
|
|
1331
|
+
return { stagedProjectDir, stagedBaseVersion };
|
|
1332
|
+
}
|
|
1333
|
+
function resolveCodexModel(model) {
|
|
1334
|
+
const normalized = model.trim();
|
|
1335
|
+
if (!normalized) {
|
|
1336
|
+
throw new Error('agent_task_assignment_model_missing');
|
|
1337
|
+
}
|
|
1338
|
+
const effortMatch = normalized.match(/^(.*)-(low|medium|high|extra-high|xhigh|max)$/);
|
|
1339
|
+
if (effortMatch?.[1] && effortMatch[2]) {
|
|
1340
|
+
const effort = effortMatch[2] === 'extra-high' ? 'xhigh' : effortMatch[2];
|
|
1341
|
+
return { model: effortMatch[1], reasoningEffort: effort };
|
|
1342
|
+
}
|
|
1343
|
+
return { model: normalized, reasoningEffort: 'medium' };
|
|
1344
|
+
}
|
|
1345
|
+
function resolveClaudeModel(model) {
|
|
1346
|
+
const normalized = model.trim();
|
|
1347
|
+
if (!normalized) {
|
|
1348
|
+
throw new Error('agent_task_assignment_model_missing');
|
|
1349
|
+
}
|
|
1350
|
+
const effortMatch = normalized.match(/^(.*)-(low|medium|high|xhigh|max)$/);
|
|
1351
|
+
if (effortMatch?.[1] && effortMatch[2]) {
|
|
1352
|
+
return { model: resolveClaudeRuntimeModel(effortMatch[1]), effort: effortMatch[2] };
|
|
1353
|
+
}
|
|
1354
|
+
return { model: resolveClaudeRuntimeModel(normalized), effort: null };
|
|
1355
|
+
}
|
|
1356
|
+
function resolveClaudeRuntimeModel(model) {
|
|
1357
|
+
if (model === 'claude-haiku-4-5') {
|
|
1358
|
+
return 'claude-haiku-4-5-20251001';
|
|
1359
|
+
}
|
|
1360
|
+
return model;
|
|
1361
|
+
}
|
|
1362
|
+
async function runCodex(input) {
|
|
1363
|
+
return await (0, runtime_1.runLoggedProcess)({
|
|
1364
|
+
command: 'codex',
|
|
1365
|
+
args: (0, runtime_1.buildCodexExecArgs)({
|
|
1366
|
+
workspaceDir: input.workspaceDir,
|
|
1367
|
+
model: input.codexModel.model,
|
|
1368
|
+
reasoningEffort: input.codexModel.reasoningEffort,
|
|
1369
|
+
networkAccess: (0, runtime_1.readEnvBoolean)('PLAYDROP_WORKER_AGENT_NETWORK_ACCESS', false),
|
|
1370
|
+
sandboxMode: (0, runtime_1.readCodexSandboxMode)(),
|
|
1371
|
+
}),
|
|
1372
|
+
cwd: input.workspaceDir,
|
|
1373
|
+
env: (0, runtime_1.buildWorkerChildEnv)({
|
|
1374
|
+
binDir: input.binDir,
|
|
1375
|
+
taskId: input.taskId,
|
|
1376
|
+
attempt: input.attempt,
|
|
1377
|
+
envName: input.envName,
|
|
1378
|
+
eventDir: input.eventDir,
|
|
1379
|
+
devPort: input.devPort,
|
|
1380
|
+
}),
|
|
1381
|
+
stdin: input.prompt,
|
|
1382
|
+
timeoutMs: (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_CODEX_TIMEOUT_MS', runtime_1.DEFAULT_CODEX_TIMEOUT_MS),
|
|
1383
|
+
maxOutputChars: runtime_1.DEFAULT_CODEX_LOG_TAIL_CHARS,
|
|
1384
|
+
onTranscriptChunks: input.onTranscriptChunks,
|
|
1385
|
+
transcriptFlushIntervalMs: runtime_1.DEFAULT_TRANSCRIPT_FLUSH_INTERVAL_MS,
|
|
1386
|
+
onChild: input.onChild,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
async function runClaude(input) {
|
|
1390
|
+
const denyReadRoots = resolveClaudeDenyReadRoots(input.workspaceDir);
|
|
1391
|
+
return await (0, runtime_1.runLoggedProcess)({
|
|
1392
|
+
command: 'claude',
|
|
1393
|
+
args: (0, runtime_1.buildClaudeExecArgs)({
|
|
1394
|
+
...input.claudeModel,
|
|
1395
|
+
workspaceDir: input.workspaceDir,
|
|
1396
|
+
denyReadRoots,
|
|
1397
|
+
pluginDir: input.playdropPluginRoot,
|
|
1398
|
+
}),
|
|
1399
|
+
cwd: input.workspaceDir,
|
|
1400
|
+
env: (0, runtime_1.buildWorkerChildEnv)({
|
|
1401
|
+
binDir: input.binDir,
|
|
1402
|
+
taskId: input.taskId,
|
|
1403
|
+
attempt: input.attempt,
|
|
1404
|
+
envName: input.envName,
|
|
1405
|
+
eventDir: input.eventDir,
|
|
1406
|
+
devPort: input.devPort,
|
|
1407
|
+
}),
|
|
1408
|
+
stdin: input.prompt,
|
|
1409
|
+
timeoutMs: (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_CLAUDE_TIMEOUT_MS', runtime_1.DEFAULT_CODEX_TIMEOUT_MS),
|
|
1410
|
+
maxOutputChars: runtime_1.DEFAULT_CODEX_LOG_TAIL_CHARS,
|
|
1411
|
+
onTranscriptChunks: input.onTranscriptChunks,
|
|
1412
|
+
transcriptFlushIntervalMs: runtime_1.DEFAULT_TRANSCRIPT_FLUSH_INTERVAL_MS,
|
|
1413
|
+
onChild: input.onChild,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
function isPathInside(parent, child) {
|
|
1417
|
+
const relative = node_path_1.default.relative(parent, child);
|
|
1418
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative));
|
|
1419
|
+
}
|
|
1420
|
+
function resolveClaudeDenyReadRoots(workspaceDir) {
|
|
1421
|
+
const workspaceRoot = node_path_1.default.resolve(workspaceDir);
|
|
1422
|
+
const candidates = new Set();
|
|
1423
|
+
candidates.add(node_process_1.default.cwd());
|
|
1424
|
+
const extra = node_process_1.default.env.PLAYDROP_WORKER_CLAUDE_DENY_ROOTS?.trim();
|
|
1425
|
+
if (extra) {
|
|
1426
|
+
for (const entry of extra.split(',')) {
|
|
1427
|
+
const root = entry.trim();
|
|
1428
|
+
if (root) {
|
|
1429
|
+
candidates.add(root);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const roots = [];
|
|
1434
|
+
for (const candidate of candidates) {
|
|
1435
|
+
const root = node_path_1.default.resolve(candidate);
|
|
1436
|
+
if (root === workspaceRoot || isPathInside(root, workspaceRoot)) {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
roots.push(root);
|
|
1440
|
+
}
|
|
1441
|
+
return roots;
|
|
1442
|
+
}
|
|
1443
|
+
// Raw stdout/stderr tails stay out of task results on purpose: transcripts are
|
|
1444
|
+
// streamed separately and results can surface to creators (spec section 11).
|
|
1445
|
+
function buildAgentRunResult(result) {
|
|
1446
|
+
return {
|
|
1447
|
+
exitCode: result.exitCode,
|
|
1448
|
+
signal: result.signal,
|
|
1449
|
+
timedOut: result.timedOut,
|
|
1450
|
+
tokensUsed: result.tokensUsed,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
function buildSupervisorFailureRunResult(message) {
|
|
1454
|
+
return {
|
|
1455
|
+
exitCode: null,
|
|
1456
|
+
signal: null,
|
|
1457
|
+
stdout: '',
|
|
1458
|
+
stderr: message,
|
|
1459
|
+
outputTail: message,
|
|
1460
|
+
timedOut: false,
|
|
1461
|
+
tokenUsage: {
|
|
1462
|
+
inputTokens: null,
|
|
1463
|
+
outputTokens: null,
|
|
1464
|
+
cacheCreationInputTokens: null,
|
|
1465
|
+
cacheReadInputTokens: null,
|
|
1466
|
+
totalTokens: null,
|
|
1467
|
+
rawProviderUsage: null,
|
|
1468
|
+
usageParseError: 'agent_not_started',
|
|
1469
|
+
},
|
|
1470
|
+
tokensUsed: null,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
function stripStagedPlaydropPluginPrefix(value) {
|
|
1474
|
+
const normalized = value.trim().replace(/\\/g, '/');
|
|
1475
|
+
const pluginPrefix = `${STAGED_PLAYDROP_PLUGIN_ROOT}/`;
|
|
1476
|
+
if (normalized.startsWith(pluginPrefix)) {
|
|
1477
|
+
return normalized.slice(pluginPrefix.length);
|
|
1478
|
+
}
|
|
1479
|
+
return normalized;
|
|
1480
|
+
}
|
|
1481
|
+
function normalizeTelemetrySkillPaths(stagedPaths) {
|
|
1482
|
+
return Array.from(new Set(stagedPaths.map(stripStagedPlaydropPluginPrefix).filter(Boolean))).sort();
|
|
1483
|
+
}
|
|
1484
|
+
function collectObservedPlaydropSkillPaths(output, availablePaths) {
|
|
1485
|
+
const observed = new Set();
|
|
1486
|
+
for (const availablePath of availablePaths) {
|
|
1487
|
+
if (output.includes(availablePath)
|
|
1488
|
+
|| output.includes(`${STAGED_PLAYDROP_PLUGIN_ROOT}/${availablePath}`)) {
|
|
1489
|
+
observed.add(availablePath);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return [...observed].sort();
|
|
1493
|
+
}
|
|
1494
|
+
function readProviderAccountLabel(target) {
|
|
1495
|
+
const label = node_process_1.default.env.PLAYDROP_WORKER_AGENT_ACCOUNT_LABEL?.trim() || null;
|
|
1496
|
+
if (target === 'FIRST_PARTY' && !label) {
|
|
1497
|
+
throw new Error('missing_playdrop_worker_agent_account_label');
|
|
1498
|
+
}
|
|
1499
|
+
return label;
|
|
1500
|
+
}
|
|
1501
|
+
function resolveTelemetryStatus(value) {
|
|
1502
|
+
if (value === 'QUEUED') {
|
|
1503
|
+
throw new Error('invalid_agent_task_run_status');
|
|
1504
|
+
}
|
|
1505
|
+
return value;
|
|
1506
|
+
}
|
|
1507
|
+
async function recordAgentRunTelemetry(input) {
|
|
1508
|
+
const observedSkillPaths = new Set(input.observedSkillPaths ?? []);
|
|
1509
|
+
for (const path of collectObservedPlaydropSkillPaths(`${input.result.stdout}\n${input.result.stderr}\n${input.result.outputTail}`, input.availableSkillPaths)) {
|
|
1510
|
+
observedSkillPaths.add(path);
|
|
1511
|
+
}
|
|
1512
|
+
await input.client.workerRecordAgentTaskRunTelemetry(input.task.id, {
|
|
1513
|
+
workerKey: input.workerKey,
|
|
1514
|
+
leaseToken: input.leaseToken,
|
|
1515
|
+
attempt: input.task.attempts,
|
|
1516
|
+
agent: input.assignment.agent,
|
|
1517
|
+
requestedModel: input.assignment.model,
|
|
1518
|
+
resolvedRuntimeModel: input.resolvedRuntimeModel,
|
|
1519
|
+
reasoningEffort: input.reasoningEffort,
|
|
1520
|
+
providerAccountLabel: input.providerAccountLabel,
|
|
1521
|
+
status: resolveTelemetryStatus(input.status),
|
|
1522
|
+
exitCode: input.result.exitCode,
|
|
1523
|
+
signal: input.result.signal,
|
|
1524
|
+
timedOut: input.result.timedOut,
|
|
1525
|
+
tokenUsage: {
|
|
1526
|
+
inputTokens: input.result.tokenUsage.inputTokens,
|
|
1527
|
+
outputTokens: input.result.tokenUsage.outputTokens,
|
|
1528
|
+
cacheCreationInputTokens: input.result.tokenUsage.cacheCreationInputTokens,
|
|
1529
|
+
cacheReadInputTokens: input.result.tokenUsage.cacheReadInputTokens,
|
|
1530
|
+
totalTokens: input.result.tokenUsage.totalTokens,
|
|
1531
|
+
rawProviderUsage: input.result.tokenUsage.rawProviderUsage,
|
|
1532
|
+
usageParseError: input.result.tokenUsage.usageParseError,
|
|
1533
|
+
},
|
|
1534
|
+
availableSkillPaths: [...input.availableSkillPaths],
|
|
1535
|
+
observedSkillPaths: [...observedSkillPaths].sort(),
|
|
1536
|
+
startedAt: input.startedAt.toISOString(),
|
|
1537
|
+
completedAt: input.completedAt.toISOString(),
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
function providerNameForAgent(agent) {
|
|
1541
|
+
return agent === 'CLAUDE_CODE' ? 'claude' : 'codex';
|
|
1542
|
+
}
|
|
1543
|
+
function extractProviderStatusCode(text) {
|
|
1544
|
+
const jsonStatus = /"api_error_status"\s*:\s*(\d{3})/.exec(text);
|
|
1545
|
+
if (jsonStatus?.[1]) {
|
|
1546
|
+
return Number(jsonStatus[1]);
|
|
1547
|
+
}
|
|
1548
|
+
const apiStatus = /\bAPI Error:\s*(\d{3})\b/i.exec(text);
|
|
1549
|
+
if (apiStatus?.[1]) {
|
|
1550
|
+
return Number(apiStatus[1]);
|
|
1551
|
+
}
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
function buildAgentFailureCode(agent, result) {
|
|
1555
|
+
const provider = providerNameForAgent(agent);
|
|
1556
|
+
const combinedOutput = `${result.outputTail}\n${result.stderr}\n${result.stdout}`;
|
|
1557
|
+
const providerStatus = extractProviderStatusCode(combinedOutput);
|
|
1558
|
+
if (providerStatus === 529 || (/\b529\b/.test(combinedOutput) && /\boverloaded?\b/i.test(combinedOutput))) {
|
|
1559
|
+
return `agent_provider_overloaded:${provider}:529`;
|
|
1560
|
+
}
|
|
1561
|
+
if (/\boverloaded?\b/i.test(combinedOutput)) {
|
|
1562
|
+
return `agent_provider_overloaded:${provider}`;
|
|
1563
|
+
}
|
|
1564
|
+
if (result.timedOut) {
|
|
1565
|
+
return `agent_provider_timeout:${provider}`;
|
|
1566
|
+
}
|
|
1567
|
+
if (result.exitCode !== null) {
|
|
1568
|
+
return `agent_unknown_exit:${provider}:${result.exitCode}`;
|
|
1569
|
+
}
|
|
1570
|
+
if (result.signal) {
|
|
1571
|
+
return `agent_unknown_exit:${provider}:signal_${result.signal}`;
|
|
1572
|
+
}
|
|
1573
|
+
return `agent_unknown_exit:${provider}`;
|
|
1574
|
+
}
|
|
1575
|
+
function describeAgentFailureForEvent(errorCode) {
|
|
1576
|
+
const [code, provider, detail] = errorCode.split(':');
|
|
1577
|
+
const providerLabel = provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : 'The agent provider';
|
|
1578
|
+
if (code === 'agent_provider_overloaded') {
|
|
1579
|
+
return detail
|
|
1580
|
+
? `${providerLabel} is overloaded (API ${detail}). Retry this task in a few minutes.`
|
|
1581
|
+
: `${providerLabel} is overloaded. Retry this task in a few minutes.`;
|
|
1582
|
+
}
|
|
1583
|
+
if (code === 'agent_provider_timeout') {
|
|
1584
|
+
return `${providerLabel} timed out before completing the task. Retry this task when the provider is healthy.`;
|
|
1585
|
+
}
|
|
1586
|
+
if (code === 'worker_shutdown') {
|
|
1587
|
+
return 'The worker stopped while running this task. Start the worker again, then retry the task.';
|
|
1588
|
+
}
|
|
1589
|
+
if (code === 'agent_unknown_exit') {
|
|
1590
|
+
return detail
|
|
1591
|
+
? `${providerLabel} exited before completing the task (${detail}). Check the transcript, then retry.`
|
|
1592
|
+
: `${providerLabel} exited before completing the task. Check the transcript, then retry.`;
|
|
1593
|
+
}
|
|
1594
|
+
return 'The agent stopped before completing the task. Check the transcript, then retry.';
|
|
1595
|
+
}
|
|
1596
|
+
function normalizeWorkerFailureErrorCode(message) {
|
|
1597
|
+
const trimmed = message.trim();
|
|
1598
|
+
const match = /^([a-z0-9_]+)(?::|\b)/.exec(trimmed);
|
|
1599
|
+
return match?.[1] ?? trimmed;
|
|
1600
|
+
}
|
|
1601
|
+
async function createLocalPlaydropShim(workspaceDir) {
|
|
1602
|
+
const binDir = node_path_1.default.join(workspaceDir, 'bin');
|
|
1603
|
+
await (0, promises_1.mkdir)(binDir, { recursive: true });
|
|
1604
|
+
const cliEntrypoint = node_path_1.default.resolve(node_process_1.default.argv[1] ?? node_path_1.default.join(__dirname, '..', 'index.js'));
|
|
1605
|
+
const shimPath = node_path_1.default.join(binDir, 'playdrop');
|
|
1606
|
+
await (0, promises_1.writeFile)(shimPath, `#!/bin/sh\nexec ${JSON.stringify(node_process_1.default.execPath)} ${JSON.stringify(cliEntrypoint)} "$@"\n`, 'utf8');
|
|
1607
|
+
await (0, promises_1.chmod)(shimPath, 0o755);
|
|
1608
|
+
return binDir;
|
|
1609
|
+
}
|
|
1610
|
+
function probeCodexInstallation() {
|
|
1611
|
+
const probe = (0, shellProbe_1.runShell)((0, shellProbe_1.buildCommandPathProbe)('codex'));
|
|
1612
|
+
if (probe.exitCode !== 0 || !probe.output.trim()) {
|
|
1613
|
+
return null;
|
|
1614
|
+
}
|
|
1615
|
+
const versionResult = (0, shellProbe_1.runShell)('codex --version');
|
|
1616
|
+
const match = /(\d+\.\d+\.\d+\S*)/.exec(versionResult.output);
|
|
1617
|
+
if (!match?.[1]) {
|
|
1618
|
+
throw new Error(`codex_version_check_failed: "codex --version" exited with code ${versionResult.exitCode} and reported no version. Output: ${versionResult.output.slice(0, 200)}`);
|
|
1619
|
+
}
|
|
1620
|
+
if (versionResult.exitCode !== 0) {
|
|
1621
|
+
// Codex 0.128.0 intermittently segfaults on exit after printing the
|
|
1622
|
+
// version; the printed version is the probe's success criterion.
|
|
1623
|
+
console.warn(`codex --version exited uncleanly (code ${versionResult.exitCode}) but reported ${match[1]}; continuing.`);
|
|
1624
|
+
}
|
|
1625
|
+
return { codexVersion: match[1] };
|
|
1626
|
+
}
|
|
1627
|
+
function probeClaudeInstallation() {
|
|
1628
|
+
const probe = (0, shellProbe_1.runShell)((0, shellProbe_1.buildCommandPathProbe)('claude'));
|
|
1629
|
+
if (probe.exitCode !== 0 || !probe.output.trim()) {
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
const versionResult = (0, shellProbe_1.runShell)('claude --version');
|
|
1633
|
+
const match = /(\d+\.\d+\.\d+\S*)/.exec(versionResult.output);
|
|
1634
|
+
if (!match?.[1] || versionResult.exitCode !== 0) {
|
|
1635
|
+
throw new Error(`claude_version_check_failed: "claude --version" exited with code ${versionResult.exitCode} and reported no version. Output: ${versionResult.output.slice(0, 200)}`);
|
|
1636
|
+
}
|
|
1637
|
+
return { claudeVersion: match[1] };
|
|
1638
|
+
}
|
|
1639
|
+
function readClaudeAuthenticated() {
|
|
1640
|
+
const status = (0, shellProbe_1.runShell)('claude auth status --text');
|
|
1641
|
+
return status.exitCode === 0;
|
|
1642
|
+
}
|
|
1643
|
+
async function failTaskWithRetry(client, taskId, body) {
|
|
1644
|
+
try {
|
|
1645
|
+
await client.workerFailAgentTask(taskId, body);
|
|
1646
|
+
}
|
|
1647
|
+
catch (firstError) {
|
|
1648
|
+
if (isAgentTaskNotRunningError(firstError)) {
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
await sleep(FAIL_REPORT_RETRY_DELAY_MS);
|
|
1652
|
+
try {
|
|
1653
|
+
await client.workerFailAgentTask(taskId, body);
|
|
1654
|
+
}
|
|
1655
|
+
catch (secondError) {
|
|
1656
|
+
if (isAgentTaskNotRunningError(secondError)) {
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
console.error(`FAILED to report agent task ${taskId} failure after retry. ` +
|
|
1660
|
+
`first=${firstError instanceof Error ? firstError.message : String(firstError)} ` +
|
|
1661
|
+
`second=${secondError instanceof Error ? secondError.message : String(secondError)}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
async function fetchTaskDetail(client, target, taskId) {
|
|
1666
|
+
if (target === 'FIRST_PARTY') {
|
|
1667
|
+
const detail = await client.adminGetAgentTask(taskId);
|
|
1668
|
+
return { task: detail.task };
|
|
1669
|
+
}
|
|
1670
|
+
return await client.getAgentTask(taskId);
|
|
1671
|
+
}
|
|
1672
|
+
async function startWorker(options = {}) {
|
|
1673
|
+
loadWorkerEnvFile(options.env);
|
|
1674
|
+
await (0, commandContext_1.withEnvironment)('worker start', 'Starting the PlayDrop worker', async ({ client, env }) => {
|
|
1675
|
+
let me;
|
|
1676
|
+
try {
|
|
1677
|
+
me = await client.me();
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
if (isWorkerAuthFailureError(error)) {
|
|
1681
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
1682
|
+
}
|
|
1683
|
+
throw error;
|
|
1684
|
+
}
|
|
1685
|
+
const username = me.user?.username?.trim();
|
|
1686
|
+
if (!username) {
|
|
1687
|
+
throw new Error('worker_session_invalid: the stored session did not resolve to a user. Run "playdrop auth login" and retry.');
|
|
1688
|
+
}
|
|
1689
|
+
const target = resolveWorkerExecutionTargetFromRole(me.user.role);
|
|
1690
|
+
const codexVersion = probeCodexInstallation()?.codexVersion ?? null;
|
|
1691
|
+
const claudeVersion = probeClaudeInstallation()?.claudeVersion ?? null;
|
|
1692
|
+
const codexAuthenticated = codexVersion ? readCodexAuthenticated() : false;
|
|
1693
|
+
const claudeAuthenticated = claudeVersion ? readClaudeAuthenticated() : false;
|
|
1694
|
+
const npmVersion = probeNpmVersion();
|
|
1695
|
+
const playwright = probePlaywrightChromium();
|
|
1696
|
+
const playdropPluginRoot = resolvePlaydropPluginRoot();
|
|
1697
|
+
const workerKey = (0, config_1.getOrCreateWorkerKey)();
|
|
1698
|
+
const workerName = options.name?.trim() || `${username} - ${node_os_1.default.hostname()}`;
|
|
1699
|
+
const maxParallelTasks = (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_MAX_PARALLEL_TASKS', DEFAULT_WORKER_MAX_PARALLEL_TASKS);
|
|
1700
|
+
const capabilities = buildWorkerCapabilities({
|
|
1701
|
+
codexVersion,
|
|
1702
|
+
codexAuthenticated,
|
|
1703
|
+
claudeVersion,
|
|
1704
|
+
claudeAuthenticated,
|
|
1705
|
+
npmVersion,
|
|
1706
|
+
playwright,
|
|
1707
|
+
maxParallelTasks,
|
|
1708
|
+
runningTaskCount: 0,
|
|
1709
|
+
});
|
|
1710
|
+
if (!capabilities.ready) {
|
|
1711
|
+
const reasons = Array.isArray(capabilities.degradedReasons) && capabilities.degradedReasons.length > 0
|
|
1712
|
+
? capabilities.degradedReasons.join(', ')
|
|
1713
|
+
: 'no ready agent';
|
|
1714
|
+
throw new Error(`worker_preflight_failed: ${reasons}`);
|
|
1715
|
+
}
|
|
1716
|
+
const activeTaskIds = new Set();
|
|
1717
|
+
const activeTaskRuns = new Map();
|
|
1718
|
+
const activeTerminators = new Map();
|
|
1719
|
+
const activeDevPorts = new Set();
|
|
1720
|
+
const workerDevPortBase = (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_DEV_PORT_BASE', DEFAULT_WORKER_DEV_PORT_BASE);
|
|
1721
|
+
let sessionExpired = false;
|
|
1722
|
+
let shuttingDown = false;
|
|
1723
|
+
let shutdownSignal = null;
|
|
1724
|
+
let crashed = false;
|
|
1725
|
+
let fatalTaskError = null;
|
|
1726
|
+
let presenceTimer = null;
|
|
1727
|
+
const firstActiveTaskId = () => activeTaskIds.values().next().value ?? null;
|
|
1728
|
+
const terminateActiveTasks = () => {
|
|
1729
|
+
for (const terminate of activeTerminators.values()) {
|
|
1730
|
+
terminate();
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
const currentCapabilities = () => ({
|
|
1734
|
+
...capabilities,
|
|
1735
|
+
runningTaskCount: activeTaskRuns.size,
|
|
1736
|
+
});
|
|
1737
|
+
const presenceBody = () => ({
|
|
1738
|
+
workerKey,
|
|
1739
|
+
name: workerName,
|
|
1740
|
+
environment: env,
|
|
1741
|
+
capabilities: currentCapabilities(),
|
|
1742
|
+
});
|
|
1743
|
+
const recordFatalTaskError = (error) => {
|
|
1744
|
+
if (!fatalTaskError) {
|
|
1745
|
+
fatalTaskError = error instanceof Error ? error : new Error(String(error));
|
|
1746
|
+
}
|
|
1747
|
+
shuttingDown = true;
|
|
1748
|
+
terminateActiveTasks();
|
|
1749
|
+
};
|
|
1750
|
+
const signalHandler = (signal) => {
|
|
1751
|
+
shutdownSignal = signal;
|
|
1752
|
+
if (shuttingDown) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
shuttingDown = true;
|
|
1756
|
+
// Task runs finish their own lifecycle (flush, fail, clean) before exit.
|
|
1757
|
+
terminateActiveTasks();
|
|
1758
|
+
};
|
|
1759
|
+
node_process_1.default.once('SIGINT', signalHandler);
|
|
1760
|
+
node_process_1.default.once('SIGTERM', signalHandler);
|
|
1761
|
+
console.log(`PlayDrop Worker started as "${workerName}" (${target}).`);
|
|
1762
|
+
await sendWorkerHealthAlertSafely({ state: 'started', env, workerName });
|
|
1763
|
+
// Marks the session expired, kills any in-flight agent child, and lets the
|
|
1764
|
+
// run loop finish the task lifecycle before the worker stops non-zero.
|
|
1765
|
+
const handleSessionExpiry = () => {
|
|
1766
|
+
if (sessionExpired) {
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
sessionExpired = true;
|
|
1770
|
+
console.error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
1771
|
+
terminateActiveTasks();
|
|
1772
|
+
};
|
|
1773
|
+
try {
|
|
1774
|
+
await client.workerPresence(presenceBody());
|
|
1775
|
+
}
|
|
1776
|
+
catch (error) {
|
|
1777
|
+
if (isWorkerAuthFailureError(error)) {
|
|
1778
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
1779
|
+
}
|
|
1780
|
+
throw error;
|
|
1781
|
+
}
|
|
1782
|
+
presenceTimer = setInterval(() => {
|
|
1783
|
+
client.workerPresence(presenceBody()).catch((error) => {
|
|
1784
|
+
if (isWorkerAuthFailureError(error)) {
|
|
1785
|
+
handleSessionExpiry();
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
console.error(`Worker presence heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1789
|
+
});
|
|
1790
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1791
|
+
const runClaimedTask = async (claim, devPort) => {
|
|
1792
|
+
const task = claim.task;
|
|
1793
|
+
if (!task) {
|
|
1794
|
+
throw new Error('agent_task_claim_missing_task');
|
|
1795
|
+
}
|
|
1796
|
+
const leaseToken = claim.leaseToken?.trim();
|
|
1797
|
+
if (!leaseToken) {
|
|
1798
|
+
throw new Error('agent_task_claim_missing_lease_token');
|
|
1799
|
+
}
|
|
1800
|
+
// The lease token lives only in supervisor memory, never in the task
|
|
1801
|
+
// workspace, on-disk worker state, or the agent child environment.
|
|
1802
|
+
let heartbeatTimer = null;
|
|
1803
|
+
let eventDrainTimer = null;
|
|
1804
|
+
let eventDrainPromise = Promise.resolve();
|
|
1805
|
+
let fenced = false;
|
|
1806
|
+
let retainWorkspace = false;
|
|
1807
|
+
let workspaceDir = null;
|
|
1808
|
+
let availableSkillPaths = [];
|
|
1809
|
+
const observedSkillPaths = new Set();
|
|
1810
|
+
let assignment = null;
|
|
1811
|
+
let providerAccountLabel = null;
|
|
1812
|
+
let resolvedRuntimeModel = task.model;
|
|
1813
|
+
let reasoningEffort = null;
|
|
1814
|
+
let agentStartedAt = null;
|
|
1815
|
+
let agentCompletedAt = null;
|
|
1816
|
+
let agentResult = null;
|
|
1817
|
+
let telemetryReported = false;
|
|
1818
|
+
const reportTelemetry = async (status, setupError) => {
|
|
1819
|
+
if (!assignment) {
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const completedAt = agentCompletedAt ?? new Date();
|
|
1823
|
+
await recordAgentRunTelemetry({
|
|
1824
|
+
client,
|
|
1825
|
+
task,
|
|
1826
|
+
assignment,
|
|
1827
|
+
workerKey,
|
|
1828
|
+
leaseToken,
|
|
1829
|
+
result: agentResult ?? buildSupervisorFailureRunResult(setupError ?? 'worker_setup_failed'),
|
|
1830
|
+
status,
|
|
1831
|
+
resolvedRuntimeModel,
|
|
1832
|
+
reasoningEffort,
|
|
1833
|
+
providerAccountLabel,
|
|
1834
|
+
availableSkillPaths,
|
|
1835
|
+
observedSkillPaths: [...observedSkillPaths],
|
|
1836
|
+
startedAt: agentStartedAt ?? completedAt,
|
|
1837
|
+
completedAt,
|
|
1838
|
+
});
|
|
1839
|
+
telemetryReported = true;
|
|
1840
|
+
};
|
|
1841
|
+
try {
|
|
1842
|
+
assignment = resolveWorkerClaimTaskAssignment(claim);
|
|
1843
|
+
if (!assignment) {
|
|
1844
|
+
throw new Error('agent_task_claim_missing_task_assignment');
|
|
1845
|
+
}
|
|
1846
|
+
providerAccountLabel = readProviderAccountLabel(assignment.target);
|
|
1847
|
+
const codexModel = assignment.agent === 'CODEX' ? resolveCodexModel(assignment.model) : null;
|
|
1848
|
+
const claudeModel = assignment.agent === 'CLAUDE_CODE' ? resolveClaudeModel(assignment.model) : null;
|
|
1849
|
+
resolvedRuntimeModel = codexModel?.model ?? claudeModel?.model ?? assignment.model;
|
|
1850
|
+
reasoningEffort = codexModel?.reasoningEffort ?? claudeModel?.effort ?? null;
|
|
1851
|
+
const taskContext = buildWorkerTaskContext(claim, assignment);
|
|
1852
|
+
workspaceDir = workerTaskWorkspaceDirFromAssignment(assignment);
|
|
1853
|
+
await (0, promises_1.rm)(workspaceDir, { recursive: true, force: true });
|
|
1854
|
+
await (0, promises_1.mkdir)(workspaceDir, { recursive: true });
|
|
1855
|
+
const eventDir = node_path_1.default.join(workspaceDir, '.playdrop-task-events');
|
|
1856
|
+
await (0, promises_1.mkdir)(eventDir, { recursive: true });
|
|
1857
|
+
const binDir = await createLocalPlaydropShim(workspaceDir);
|
|
1858
|
+
await stageWorkerTaskContext({
|
|
1859
|
+
workspaceDir,
|
|
1860
|
+
env,
|
|
1861
|
+
devPort,
|
|
1862
|
+
taskContext,
|
|
1863
|
+
});
|
|
1864
|
+
const handleEventDrainFailure = (error) => {
|
|
1865
|
+
const classification = classifyWorkerEventDrainError(error);
|
|
1866
|
+
if (classification === 'session_expired') {
|
|
1867
|
+
handleSessionExpiry();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (classification === 'lease_invalid') {
|
|
1871
|
+
fenced = true;
|
|
1872
|
+
activeTerminators.get(task.id)?.();
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
console.error(`Agent task event drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1876
|
+
};
|
|
1877
|
+
const queueEventDrain = () => {
|
|
1878
|
+
eventDrainPromise = eventDrainPromise.then(() => drainWorkerEventQueue({ eventDir, client, taskId: task.id, workerKey, leaseToken }), () => drainWorkerEventQueue({ eventDir, client, taskId: task.id, workerKey, leaseToken }));
|
|
1879
|
+
return eventDrainPromise;
|
|
1880
|
+
};
|
|
1881
|
+
const appendObservedSkillPathsFromTranscript = (chunks) => {
|
|
1882
|
+
if (availableSkillPaths.length <= 0) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
for (const chunk of chunks) {
|
|
1886
|
+
for (const observedPath of collectObservedPlaydropSkillPaths(chunk.content, availableSkillPaths)) {
|
|
1887
|
+
observedSkillPaths.add(observedPath);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
const appendTranscriptChunks = async (chunks) => {
|
|
1892
|
+
appendObservedSkillPathsFromTranscript(chunks);
|
|
1893
|
+
await appendWorkerTranscriptChunks({ client, taskId: task.id, workerKey, leaseToken, chunks });
|
|
1894
|
+
};
|
|
1895
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
1896
|
+
workerKey,
|
|
1897
|
+
leaseToken,
|
|
1898
|
+
kind: 'progress',
|
|
1899
|
+
phase: 'setup',
|
|
1900
|
+
message: 'Preparing worker workspace',
|
|
1901
|
+
pct: 2,
|
|
1902
|
+
});
|
|
1903
|
+
availableSkillPaths = normalizeTelemetrySkillPaths(await stagePlaydropPluginReferences({ workspaceDir, pluginRoot: playdropPluginRoot, kind: task.kind }));
|
|
1904
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
1905
|
+
workerKey,
|
|
1906
|
+
leaseToken,
|
|
1907
|
+
kind: 'progress',
|
|
1908
|
+
phase: 'setup',
|
|
1909
|
+
message: 'Staged PlayDrop plugin references',
|
|
1910
|
+
pct: 3,
|
|
1911
|
+
});
|
|
1912
|
+
if (assignment.workspace.files.length > 0) {
|
|
1913
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
1914
|
+
workerKey,
|
|
1915
|
+
leaseToken,
|
|
1916
|
+
kind: 'progress',
|
|
1917
|
+
phase: 'setup',
|
|
1918
|
+
message: `Staging ${assignment.workspace.files.length} server-provided workspace file${assignment.workspace.files.length === 1 ? '' : 's'}`,
|
|
1919
|
+
pct: 3,
|
|
1920
|
+
});
|
|
1921
|
+
await stageAssignmentWorkspaceFiles(workspaceDir, assignment.workspace.files);
|
|
1922
|
+
}
|
|
1923
|
+
heartbeatTimer = setInterval(() => {
|
|
1924
|
+
client.workerHeartbeatAgentTask(task.id, { workerKey, leaseToken })
|
|
1925
|
+
.then((heartbeat) => {
|
|
1926
|
+
if (heartbeat.action === 'abort') {
|
|
1927
|
+
fenced = true;
|
|
1928
|
+
activeTerminators.get(task.id)?.();
|
|
1929
|
+
}
|
|
1930
|
+
})
|
|
1931
|
+
.catch((error) => {
|
|
1932
|
+
if (isWorkerAuthFailureError(error)) {
|
|
1933
|
+
handleSessionExpiry();
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (isLeaseInvalidError(error)) {
|
|
1937
|
+
fenced = true;
|
|
1938
|
+
activeTerminators.get(task.id)?.();
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
console.error(`Agent task heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1942
|
+
});
|
|
1943
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1944
|
+
eventDrainTimer = setInterval(() => {
|
|
1945
|
+
queueEventDrain().catch((error) => {
|
|
1946
|
+
handleEventDrainFailure(error);
|
|
1947
|
+
});
|
|
1948
|
+
}, 1000);
|
|
1949
|
+
const prompt = assignment.prompt;
|
|
1950
|
+
if (task.kind === 'GAME_UPDATE') {
|
|
1951
|
+
const baseSourcePayload = assignment.inputs.baseSource;
|
|
1952
|
+
const baseSource = resolveWorkerBaseSource(baseSourcePayload);
|
|
1953
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
1954
|
+
workerKey,
|
|
1955
|
+
leaseToken,
|
|
1956
|
+
kind: 'progress',
|
|
1957
|
+
phase: 'setup',
|
|
1958
|
+
message: `Staging base source ${baseSource.appName} version ${baseSource.version} into the workspace`,
|
|
1959
|
+
pct: 4,
|
|
1960
|
+
});
|
|
1961
|
+
await stageWorkerBaseAppSource({
|
|
1962
|
+
client,
|
|
1963
|
+
workspaceDir,
|
|
1964
|
+
creatorUsername: baseSourcePayload.creatorUsername,
|
|
1965
|
+
baseApp: baseSource,
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
else if (task.kind === 'NEW_GAME') {
|
|
1969
|
+
if (assignment.inputs.baseSource !== null) {
|
|
1970
|
+
throw new Error('agent_task_assignment_unexpected_base_source');
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
else if (task.kind === 'GAME_REVIEW') {
|
|
1974
|
+
if (assignment.inputs.baseSource !== null) {
|
|
1975
|
+
throw new Error('agent_task_assignment_unexpected_base_source');
|
|
1976
|
+
}
|
|
1977
|
+
if (!assignment.inputs.reviewTarget) {
|
|
1978
|
+
throw new Error('agent_task_assignment_review_target_missing');
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
throw new Error(`unsupported_agent_task_kind:${task.kind}`);
|
|
1983
|
+
}
|
|
1984
|
+
if (shuttingDown) {
|
|
1985
|
+
throw new Error('worker_shutdown');
|
|
1986
|
+
}
|
|
1987
|
+
agentStartedAt = new Date();
|
|
1988
|
+
agentResult = assignment.agent === 'CODEX'
|
|
1989
|
+
? await runCodex({
|
|
1990
|
+
workspaceDir,
|
|
1991
|
+
binDir,
|
|
1992
|
+
eventDir,
|
|
1993
|
+
prompt,
|
|
1994
|
+
envName: env,
|
|
1995
|
+
taskId: task.id,
|
|
1996
|
+
attempt: taskContext.attempt,
|
|
1997
|
+
devPort,
|
|
1998
|
+
codexModel: codexModel ?? resolveCodexModel(assignment.model),
|
|
1999
|
+
onTranscriptChunks: async (chunks) => {
|
|
2000
|
+
await appendTranscriptChunks(chunks);
|
|
2001
|
+
},
|
|
2002
|
+
onChild: (controls) => {
|
|
2003
|
+
activeTerminators.set(task.id, controls.terminate);
|
|
2004
|
+
},
|
|
2005
|
+
})
|
|
2006
|
+
: await runClaude({
|
|
2007
|
+
workspaceDir,
|
|
2008
|
+
binDir,
|
|
2009
|
+
eventDir,
|
|
2010
|
+
prompt,
|
|
2011
|
+
envName: env,
|
|
2012
|
+
taskId: task.id,
|
|
2013
|
+
attempt: taskContext.attempt,
|
|
2014
|
+
devPort,
|
|
2015
|
+
claudeModel: claudeModel ?? resolveClaudeModel(assignment.model),
|
|
2016
|
+
playdropPluginRoot,
|
|
2017
|
+
onTranscriptChunks: async (chunks) => {
|
|
2018
|
+
await appendTranscriptChunks(chunks);
|
|
2019
|
+
},
|
|
2020
|
+
onChild: (controls) => {
|
|
2021
|
+
activeTerminators.set(task.id, controls.terminate);
|
|
2022
|
+
},
|
|
2023
|
+
});
|
|
2024
|
+
agentCompletedAt = new Date();
|
|
2025
|
+
await queueEventDrain().catch((error) => {
|
|
2026
|
+
handleEventDrainFailure(error);
|
|
2027
|
+
});
|
|
2028
|
+
const completedAgentResult = agentResult;
|
|
2029
|
+
const agentRunResult = buildAgentRunResult(completedAgentResult);
|
|
2030
|
+
if (fenced) {
|
|
2031
|
+
const refreshed = await fetchTaskDetail(client, target, task.id).catch(() => null);
|
|
2032
|
+
if (refreshed && refreshed.task.status !== 'RUNNING' && refreshed.task.status !== 'QUEUED') {
|
|
2033
|
+
retainWorkspace = refreshed.task.status === 'FAILED';
|
|
2034
|
+
console.error(`Agent task ${task.id} was finalized by the server as ${refreshed.task.status}.`);
|
|
2035
|
+
await reportTelemetry(refreshed.task.status);
|
|
2036
|
+
}
|
|
2037
|
+
else {
|
|
2038
|
+
console.error(`Agent task ${task.id} was aborted by the server; cleaning up without reporting failure.`);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
else if (sessionExpired) {
|
|
2042
|
+
// Best effort: the session is already invalid, so this fail call
|
|
2043
|
+
// likely 401s too; failTaskWithRetry logs loudly after the retry.
|
|
2044
|
+
await failTaskWithRetry(client, task.id, {
|
|
2045
|
+
workerKey,
|
|
2046
|
+
leaseToken,
|
|
2047
|
+
error: 'worker_session_expired',
|
|
2048
|
+
result: agentRunResult,
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
else if (shuttingDown) {
|
|
2052
|
+
await failTaskWithRetry(client, task.id, {
|
|
2053
|
+
workerKey,
|
|
2054
|
+
leaseToken,
|
|
2055
|
+
error: 'worker_shutdown',
|
|
2056
|
+
result: agentRunResult,
|
|
2057
|
+
});
|
|
2058
|
+
await reportTelemetry('FAILED');
|
|
2059
|
+
}
|
|
2060
|
+
else if (completedAgentResult.timedOut || completedAgentResult.exitCode !== 0 || completedAgentResult.signal) {
|
|
2061
|
+
const refreshed = await fetchTaskDetail(client, target, task.id);
|
|
2062
|
+
if (refreshed.task.status !== 'RUNNING') {
|
|
2063
|
+
retainWorkspace = refreshed.task.status === 'FAILED';
|
|
2064
|
+
console.error(`Agent task ${task.id} was finalized by the agent as ${refreshed.task.status}.`);
|
|
2065
|
+
await reportTelemetry(refreshed.task.status);
|
|
2066
|
+
}
|
|
2067
|
+
else if (hasAgentTaskUploadedArtifact(refreshed.task)) {
|
|
2068
|
+
retainWorkspace = true;
|
|
2069
|
+
console.error(`Agent task ${task.id} already uploaded an artifact but exited before task completion. Leaving it for manual completion repair.`);
|
|
2070
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2071
|
+
workerKey,
|
|
2072
|
+
leaseToken,
|
|
2073
|
+
kind: 'progress',
|
|
2074
|
+
phase: 'upload',
|
|
2075
|
+
message: 'Agent exited after upload before task done; manual completion repair required.',
|
|
2076
|
+
});
|
|
2077
|
+
await reportTelemetry('RUNNING');
|
|
2078
|
+
}
|
|
2079
|
+
else {
|
|
2080
|
+
retainWorkspace = true;
|
|
2081
|
+
const failureCode = buildAgentFailureCode(assignment.agent, completedAgentResult);
|
|
2082
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2083
|
+
workerKey,
|
|
2084
|
+
leaseToken,
|
|
2085
|
+
kind: 'system',
|
|
2086
|
+
phase: 'failed',
|
|
2087
|
+
message: describeAgentFailureForEvent(failureCode),
|
|
2088
|
+
pct: null,
|
|
2089
|
+
payload: { error: failureCode },
|
|
2090
|
+
});
|
|
2091
|
+
await failTaskWithRetry(client, task.id, {
|
|
2092
|
+
workerKey,
|
|
2093
|
+
leaseToken,
|
|
2094
|
+
error: failureCode,
|
|
2095
|
+
result: agentRunResult,
|
|
2096
|
+
});
|
|
2097
|
+
await reportTelemetry('FAILED');
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
else {
|
|
2101
|
+
const refreshed = await fetchTaskDetail(client, target, task.id);
|
|
2102
|
+
if (refreshed.task.status !== 'RUNNING') {
|
|
2103
|
+
retainWorkspace = refreshed.task.status === 'FAILED';
|
|
2104
|
+
console.error(`Agent task ${task.id} was finalized by the agent as ${refreshed.task.status}.`);
|
|
2105
|
+
await reportTelemetry(refreshed.task.status);
|
|
2106
|
+
}
|
|
2107
|
+
else if (hasAgentTaskUploadedArtifact(refreshed.task)) {
|
|
2108
|
+
retainWorkspace = true;
|
|
2109
|
+
console.error(`Agent task ${task.id} already uploaded an artifact but exited before task completion. Leaving it for manual completion repair.`);
|
|
2110
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2111
|
+
workerKey,
|
|
2112
|
+
leaseToken,
|
|
2113
|
+
kind: 'progress',
|
|
2114
|
+
phase: 'upload',
|
|
2115
|
+
message: 'Agent exited after upload before task done; manual completion repair required.',
|
|
2116
|
+
});
|
|
2117
|
+
await reportTelemetry('RUNNING');
|
|
2118
|
+
}
|
|
2119
|
+
else {
|
|
2120
|
+
const tokenCap = (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_TOKEN_CAP', runtime_1.DEFAULT_WORKER_TOKEN_CAP);
|
|
2121
|
+
let tokenUsageKnown = true;
|
|
2122
|
+
try {
|
|
2123
|
+
tokenUsageKnown = (0, runtime_1.assertWorkerTokenUsageWithinCap)({
|
|
2124
|
+
tokensUsed: agentResult.tokensUsed,
|
|
2125
|
+
tokenCap,
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
catch (error) {
|
|
2129
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2130
|
+
workerKey,
|
|
2131
|
+
leaseToken,
|
|
2132
|
+
kind: 'progress',
|
|
2133
|
+
message: `Token usage ${completedAgentResult.tokensUsed} exceeded the worker token cap of ${tokenCap}.`,
|
|
2134
|
+
});
|
|
2135
|
+
await reportTelemetry('FAILED');
|
|
2136
|
+
throw error;
|
|
2137
|
+
}
|
|
2138
|
+
if (!tokenUsageKnown) {
|
|
2139
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2140
|
+
workerKey,
|
|
2141
|
+
leaseToken,
|
|
2142
|
+
kind: 'progress',
|
|
2143
|
+
message: 'Token usage parse failed: the agent output did not contain a recognizable total token count.',
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
await client.workerCreateAgentTaskEvent(task.id, {
|
|
2147
|
+
workerKey,
|
|
2148
|
+
leaseToken,
|
|
2149
|
+
kind: 'progress',
|
|
2150
|
+
phase: 'failed',
|
|
2151
|
+
message: 'Agent exited before running playdrop task done',
|
|
2152
|
+
});
|
|
2153
|
+
retainWorkspace = true;
|
|
2154
|
+
await failTaskWithRetry(client, task.id, {
|
|
2155
|
+
workerKey,
|
|
2156
|
+
leaseToken,
|
|
2157
|
+
error: 'agent_exited_without_task_done',
|
|
2158
|
+
result: agentRunResult,
|
|
2159
|
+
});
|
|
2160
|
+
await reportTelemetry('FAILED');
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
catch (error) {
|
|
2165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2166
|
+
if (isAgentTaskNotRunningError(error)) {
|
|
2167
|
+
const refreshed = await fetchTaskDetail(client, target, task.id).catch(() => null);
|
|
2168
|
+
retainWorkspace = refreshed?.task.status === 'FAILED';
|
|
2169
|
+
console.error(`Agent task ${task.id} was already finalized${refreshed ? ` as ${refreshed.task.status}` : ''}.`);
|
|
2170
|
+
}
|
|
2171
|
+
else {
|
|
2172
|
+
retainWorkspace = true;
|
|
2173
|
+
console.error(`Agent task ${task.id} failed in the worker: ${message}`);
|
|
2174
|
+
}
|
|
2175
|
+
if (!fenced && !isAgentTaskNotRunningError(error)) {
|
|
2176
|
+
try {
|
|
2177
|
+
await failTaskWithRetry(client, task.id, {
|
|
2178
|
+
workerKey,
|
|
2179
|
+
leaseToken,
|
|
2180
|
+
error: normalizeWorkerFailureErrorCode(message),
|
|
2181
|
+
result: { workerError: message },
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
finally {
|
|
2185
|
+
if (!telemetryReported) {
|
|
2186
|
+
try {
|
|
2187
|
+
await reportTelemetry('FAILED', message);
|
|
2188
|
+
}
|
|
2189
|
+
catch (telemetryError) {
|
|
2190
|
+
console.error(`Agent task telemetry failed: ${telemetryError instanceof Error ? telemetryError.message : String(telemetryError)}`);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
finally {
|
|
2197
|
+
if (heartbeatTimer) {
|
|
2198
|
+
clearInterval(heartbeatTimer);
|
|
2199
|
+
}
|
|
2200
|
+
if (eventDrainTimer) {
|
|
2201
|
+
clearInterval(eventDrainTimer);
|
|
2202
|
+
}
|
|
2203
|
+
activeTerminators.delete(task.id);
|
|
2204
|
+
if (retainWorkspace && (0, runtime_1.readEnvBoolean)('PLAYDROP_WORKER_RETAIN_FAILED_WORKSPACE', true)) {
|
|
2205
|
+
console.error(`Retained failed task workspace for debugging: ${workspaceDir ?? '(not created)'}`);
|
|
2206
|
+
}
|
|
2207
|
+
else if (workspaceDir) {
|
|
2208
|
+
await (0, promises_1.rm)(workspaceDir, { recursive: true, force: true });
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
if (sessionExpired) {
|
|
2212
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
try {
|
|
2216
|
+
let claimBackoffMs = CLAIM_BACKOFF_BASE_MS;
|
|
2217
|
+
while (!shuttingDown) {
|
|
2218
|
+
if (fatalTaskError) {
|
|
2219
|
+
throw fatalTaskError;
|
|
2220
|
+
}
|
|
2221
|
+
if (sessionExpired) {
|
|
2222
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
2223
|
+
}
|
|
2224
|
+
if (activeTaskRuns.size >= maxParallelTasks) {
|
|
2225
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
const devPort = allocateWorkerDevPort({
|
|
2229
|
+
activePorts: activeDevPorts,
|
|
2230
|
+
basePort: workerDevPortBase,
|
|
2231
|
+
maxParallelTasks,
|
|
2232
|
+
});
|
|
2233
|
+
let claim;
|
|
2234
|
+
try {
|
|
2235
|
+
claim = await client.workerClaimAgentTask(buildWorkerClaimBody({
|
|
2236
|
+
workerKey,
|
|
2237
|
+
capabilities,
|
|
2238
|
+
runningTaskCount: activeTaskRuns.size,
|
|
2239
|
+
}));
|
|
2240
|
+
claimBackoffMs = CLAIM_BACKOFF_BASE_MS;
|
|
2241
|
+
}
|
|
2242
|
+
catch (error) {
|
|
2243
|
+
// Auth (401) and forbidden (403) claim errors stop the worker; only
|
|
2244
|
+
// transient errors reach the backoff below.
|
|
2245
|
+
assertWorkerClaimErrorRetryable(error);
|
|
2246
|
+
console.error(`Agent task claim failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2247
|
+
await sleep(claimBackoffDelayMs(claimBackoffMs));
|
|
2248
|
+
claimBackoffMs = nextClaimBackoffMs(claimBackoffMs);
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
const task = claim.task;
|
|
2252
|
+
if (!task) {
|
|
2253
|
+
if (shuttingDown) {
|
|
2254
|
+
break;
|
|
2255
|
+
}
|
|
2256
|
+
if (options.once) {
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
activeTaskIds.add(task.id);
|
|
2263
|
+
activeDevPorts.add(devPort);
|
|
2264
|
+
const taskRun = runClaimedTask(claim, devPort)
|
|
2265
|
+
.catch((error) => {
|
|
2266
|
+
recordFatalTaskError(error);
|
|
2267
|
+
})
|
|
2268
|
+
.finally(() => {
|
|
2269
|
+
activeTaskRuns.delete(task.id);
|
|
2270
|
+
activeTaskIds.delete(task.id);
|
|
2271
|
+
activeDevPorts.delete(devPort);
|
|
2272
|
+
});
|
|
2273
|
+
activeTaskRuns.set(task.id, taskRun);
|
|
2274
|
+
if (options.once) {
|
|
2275
|
+
await taskRun;
|
|
2276
|
+
if (fatalTaskError) {
|
|
2277
|
+
throw fatalTaskError;
|
|
2278
|
+
}
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if (activeTaskRuns.size > 0) {
|
|
2283
|
+
await Promise.allSettled(Array.from(activeTaskRuns.values()));
|
|
2284
|
+
}
|
|
2285
|
+
if (fatalTaskError) {
|
|
2286
|
+
throw fatalTaskError;
|
|
2287
|
+
}
|
|
2288
|
+
if (sessionExpired) {
|
|
2289
|
+
throw new Error(exports.WORKER_SESSION_EXPIRED_MESSAGE);
|
|
2290
|
+
}
|
|
2291
|
+
if (shuttingDown) {
|
|
2292
|
+
await sendWorkerHealthAlertSafely({
|
|
2293
|
+
state: 'stopped',
|
|
2294
|
+
env,
|
|
2295
|
+
workerName,
|
|
2296
|
+
taskId: firstActiveTaskId(),
|
|
2297
|
+
detail: shutdownSignal,
|
|
2298
|
+
});
|
|
2299
|
+
node_process_1.default.exit(shutdownSignal === 'SIGINT' ? 130 : 143);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
catch (error) {
|
|
2303
|
+
crashed = true;
|
|
2304
|
+
await sendWorkerHealthAlertSafely({
|
|
2305
|
+
state: 'crashed',
|
|
2306
|
+
env,
|
|
2307
|
+
workerName,
|
|
2308
|
+
taskId: firstActiveTaskId(),
|
|
2309
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
2310
|
+
});
|
|
2311
|
+
throw error;
|
|
2312
|
+
}
|
|
2313
|
+
finally {
|
|
2314
|
+
if (presenceTimer) {
|
|
2315
|
+
clearInterval(presenceTimer);
|
|
2316
|
+
}
|
|
2317
|
+
node_process_1.default.off('SIGINT', signalHandler);
|
|
2318
|
+
node_process_1.default.off('SIGTERM', signalHandler);
|
|
2319
|
+
if (!crashed) {
|
|
2320
|
+
await sendWorkerHealthAlertSafely({
|
|
2321
|
+
state: 'stopped',
|
|
2322
|
+
env,
|
|
2323
|
+
workerName,
|
|
2324
|
+
taskId: firstActiveTaskId(),
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}, { env: options.env });
|
|
2329
|
+
}
|
|
2330
|
+
async function reportTask(options) {
|
|
2331
|
+
const message = options.message?.trim();
|
|
2332
|
+
if (!message) {
|
|
2333
|
+
(0, messages_1.printErrorWithHelp)('Task report requires a message.', ['Example: playdrop task report --phase build --pct 25 -m "Building first loop"'], { command: 'task report' });
|
|
2334
|
+
node_process_1.default.exitCode = 1;
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
const pct = parseOptionalPct(options.pct);
|
|
2338
|
+
const kind = normalizeTaskEventKind(options.kind);
|
|
2339
|
+
const phase = options.phase?.trim() || null;
|
|
2340
|
+
if (resolveWorkerEventQueueDir()) {
|
|
2341
|
+
enqueueWorkerContextEvent({ kind, phase, message, pct });
|
|
2342
|
+
(0, output_1.printSuccess)('Task progress queued.');
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
resolveWorkerTaskStateForWrite();
|
|
2346
|
+
}
|
|
2347
|
+
async function reportCatalogueTask(options) {
|
|
2348
|
+
const message = options.message?.trim();
|
|
2349
|
+
if (!message) {
|
|
2350
|
+
(0, messages_1.printErrorWithHelp)('Task catalogue report requires a message.', ['Example: playdrop task report-catalogue --file catalogue.json -m "Draft catalogue ready"'], { command: 'task report-catalogue' });
|
|
2351
|
+
node_process_1.default.exitCode = 1;
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const file = options.file?.trim();
|
|
2355
|
+
if (!file) {
|
|
2356
|
+
(0, messages_1.printErrorWithHelp)('Task catalogue report requires a catalogue file.', ['Example: playdrop task report-catalogue --file catalogue.json -m "Draft catalogue ready"'], { command: 'task report-catalogue' });
|
|
2357
|
+
node_process_1.default.exitCode = 1;
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
const cataloguePath = node_path_1.default.isAbsolute(file) ? file : node_path_1.default.resolve(node_process_1.default.cwd(), file);
|
|
2361
|
+
const payload = buildCataloguePreviewPayload(cataloguePath, node_process_1.default.cwd());
|
|
2362
|
+
if (resolveWorkerEventQueueDir()) {
|
|
2363
|
+
enqueueWorkerContextEvent({
|
|
2364
|
+
kind: 'catalogue_preview',
|
|
2365
|
+
phase: 'catalogue',
|
|
2366
|
+
message,
|
|
2367
|
+
payload,
|
|
2368
|
+
});
|
|
2369
|
+
(0, output_1.printSuccess)('Task catalogue preview queued.');
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
resolveWorkerTaskStateForWrite();
|
|
2373
|
+
}
|
|
2374
|
+
async function resolveTaskCommandContext(command, optionsEnv, taskContext) {
|
|
2375
|
+
const ctx = await (0, commandContext_1.resolveAuthenticatedEnvironmentContext)(command, command, {
|
|
2376
|
+
env: optionsEnv ?? taskContext.env,
|
|
2377
|
+
workspacePath: node_process_1.default.cwd(),
|
|
2378
|
+
});
|
|
2379
|
+
if (!ctx) {
|
|
2380
|
+
return null;
|
|
2381
|
+
}
|
|
2382
|
+
const me = await ctx.client.me();
|
|
2383
|
+
if (!me.user) {
|
|
2384
|
+
throw new Error('task_context_user_missing');
|
|
2385
|
+
}
|
|
2386
|
+
return { ...ctx, user: me.user };
|
|
2387
|
+
}
|
|
2388
|
+
async function uploadTask(options = {}) {
|
|
2389
|
+
const taskContext = readTaskContextFile();
|
|
2390
|
+
if (taskContext.kind === 'GAME_REVIEW') {
|
|
2391
|
+
throw new Error('task_upload_not_allowed_for_game_review');
|
|
2392
|
+
}
|
|
2393
|
+
const workspaceDir = resolveTaskWorkspaceDir();
|
|
2394
|
+
if ((0, node_fs_1.existsSync)(workerTaskUploadResultPath(workspaceDir))) {
|
|
2395
|
+
const existingResult = readTaskUploadResultFile(workspaceDir);
|
|
2396
|
+
assertTaskUploadResultMatchesContext({
|
|
2397
|
+
taskContext,
|
|
2398
|
+
uploadResult: existingResult,
|
|
2399
|
+
});
|
|
2400
|
+
(0, output_1.printSuccess)(`Task upload already completed for ${existingResult.creatorUsername}/${existingResult.appName} version ${existingResult.version}. Run "playdrop task done" to close the task.`);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
const ctx = await resolveTaskCommandContext('task upload', options.env, taskContext);
|
|
2404
|
+
if (!ctx) {
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
const projectDir = discoverWorkerProjectRoot(node_process_1.default.cwd());
|
|
2408
|
+
const published = await (0, upload_1.publishWorkerAppProject)({
|
|
2409
|
+
client: ctx.client,
|
|
2410
|
+
taskId: taskContext.taskId,
|
|
2411
|
+
kind: taskContext.kind,
|
|
2412
|
+
expectedAppName: taskContext.outputAppName ?? undefined,
|
|
2413
|
+
playdropAssetRequirement: resolvePlaydropAssetRequirementFromTaskRequest(taskContext.creatorRequest),
|
|
2414
|
+
creatorRequest: taskContext.creatorRequest,
|
|
2415
|
+
projectDir,
|
|
2416
|
+
creatorUsername: taskContext.creatorUsername,
|
|
2417
|
+
apiBase: ctx.envConfig.apiBase,
|
|
2418
|
+
webBase: ctx.envConfig.webBase ?? null,
|
|
2419
|
+
token: ctx.token,
|
|
2420
|
+
user: ctx.user,
|
|
2421
|
+
});
|
|
2422
|
+
const result = {
|
|
2423
|
+
taskId: taskContext.taskId,
|
|
2424
|
+
appId: published.appId,
|
|
2425
|
+
appVersionId: published.appVersionId,
|
|
2426
|
+
appName: published.appName,
|
|
2427
|
+
version: published.version,
|
|
2428
|
+
versionNodeId: published.versionNodeId,
|
|
2429
|
+
creatorUsername: published.creatorUsername,
|
|
2430
|
+
};
|
|
2431
|
+
await (0, promises_1.writeFile)(workerTaskUploadResultPath(workspaceDir), JSON.stringify(result, null, 2), 'utf8');
|
|
2432
|
+
for (const warning of published.warnings) {
|
|
2433
|
+
console.error(`Upload warning: ${warning}`);
|
|
2434
|
+
}
|
|
2435
|
+
(0, output_1.printSuccess)(`Uploaded ${published.creatorUsername}/${published.appName} version ${published.version}. Run "playdrop task done" to close the task.`);
|
|
2436
|
+
}
|
|
2437
|
+
async function completeTask(options) {
|
|
2438
|
+
const taskContext = readTaskContextFile();
|
|
2439
|
+
if (taskContext.kind === 'GAME_REVIEW') {
|
|
2440
|
+
throw new Error('task_done_not_allowed_for_game_review');
|
|
2441
|
+
}
|
|
2442
|
+
const ctx = await resolveTaskCommandContext('task done', options.env, taskContext);
|
|
2443
|
+
if (!ctx) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const uploadResult = readTaskUploadResultFile();
|
|
2447
|
+
const appId = uploadResult.appId;
|
|
2448
|
+
const appVersionId = uploadResult.appVersionId;
|
|
2449
|
+
if (!appId || !appVersionId) {
|
|
2450
|
+
throw new Error('task_done_missing_upload_result');
|
|
2451
|
+
}
|
|
2452
|
+
if (uploadResult.taskId !== taskContext.taskId) {
|
|
2453
|
+
throw new Error('task_done_upload_result_task_mismatch');
|
|
2454
|
+
}
|
|
2455
|
+
await ctx.client.workerCompleteAgentTask(taskContext.taskId, {
|
|
2456
|
+
taskToken: taskContext.taskToken,
|
|
2457
|
+
appId,
|
|
2458
|
+
appVersionId,
|
|
2459
|
+
result: {
|
|
2460
|
+
appId: uploadResult.appId,
|
|
2461
|
+
appVersionId: uploadResult.appVersionId,
|
|
2462
|
+
version: uploadResult.version,
|
|
2463
|
+
appName: uploadResult.appName,
|
|
2464
|
+
versionNodeId: uploadResult.versionNodeId,
|
|
2465
|
+
creatorUsername: uploadResult.creatorUsername,
|
|
2466
|
+
completedBy: 'agent',
|
|
2467
|
+
},
|
|
2468
|
+
});
|
|
2469
|
+
(0, output_1.printSuccess)('Task marked done.');
|
|
2470
|
+
}
|
|
2471
|
+
function normalizeReviewSubmitState(value) {
|
|
2472
|
+
const normalized = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
2473
|
+
if (normalized !== 'PASSED'
|
|
2474
|
+
&& normalized !== 'GOOD'
|
|
2475
|
+
&& normalized !== 'EXCELLENT'
|
|
2476
|
+
&& normalized !== 'LOW_QUALITY'
|
|
2477
|
+
&& normalized !== 'FAILED'
|
|
2478
|
+
&& normalized !== 'ERROR') {
|
|
2479
|
+
throw new Error('invalid_review_state');
|
|
2480
|
+
}
|
|
2481
|
+
return normalized;
|
|
2482
|
+
}
|
|
2483
|
+
function readRequiredTextFile(filePath, code) {
|
|
2484
|
+
const normalizedPath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
2485
|
+
if (!normalizedPath) {
|
|
2486
|
+
throw new Error(code);
|
|
2487
|
+
}
|
|
2488
|
+
const resolved = node_path_1.default.resolve(normalizedPath);
|
|
2489
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
2490
|
+
throw new Error(code);
|
|
2491
|
+
}
|
|
2492
|
+
const content = (0, node_fs_1.readFileSync)(resolved, 'utf8').trim();
|
|
2493
|
+
if (!content) {
|
|
2494
|
+
throw new Error(code);
|
|
2495
|
+
}
|
|
2496
|
+
return content;
|
|
2497
|
+
}
|
|
2498
|
+
function readOptionalTextFile(filePath) {
|
|
2499
|
+
const normalizedPath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
2500
|
+
if (!normalizedPath) {
|
|
2501
|
+
return null;
|
|
2502
|
+
}
|
|
2503
|
+
const resolved = node_path_1.default.resolve(normalizedPath);
|
|
2504
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
2505
|
+
throw new Error('invalid_creator_feedback_file');
|
|
2506
|
+
}
|
|
2507
|
+
const content = (0, node_fs_1.readFileSync)(resolved, 'utf8').trim();
|
|
2508
|
+
return content || null;
|
|
2509
|
+
}
|
|
2510
|
+
function contentTypeForEvidenceFile(fileName) {
|
|
2511
|
+
const extension = node_path_1.default.extname(fileName).toLowerCase();
|
|
2512
|
+
if (extension === '.png')
|
|
2513
|
+
return 'image/png';
|
|
2514
|
+
if (extension === '.jpg' || extension === '.jpeg')
|
|
2515
|
+
return 'image/jpeg';
|
|
2516
|
+
if (extension === '.webp')
|
|
2517
|
+
return 'image/webp';
|
|
2518
|
+
return 'application/octet-stream';
|
|
2519
|
+
}
|
|
2520
|
+
function readReviewEvidenceFiles(evidenceDir) {
|
|
2521
|
+
const normalizedDir = typeof evidenceDir === 'string' ? evidenceDir.trim() : '';
|
|
2522
|
+
if (!normalizedDir) {
|
|
2523
|
+
throw new Error('invalid_review_evidence_dir');
|
|
2524
|
+
}
|
|
2525
|
+
const resolvedDir = node_path_1.default.resolve(normalizedDir);
|
|
2526
|
+
if (!(0, node_fs_1.existsSync)(resolvedDir)) {
|
|
2527
|
+
throw new Error('invalid_review_evidence_dir');
|
|
2528
|
+
}
|
|
2529
|
+
const existingFiles = new Set((0, node_fs_1.readdirSync)(resolvedDir, { withFileTypes: true })
|
|
2530
|
+
.filter((entry) => entry.isFile())
|
|
2531
|
+
.map((entry) => entry.name));
|
|
2532
|
+
const files = review_1.REQUIRED_REVIEW_EVIDENCE_FILES.filter((fileName) => existingFiles.has(fileName));
|
|
2533
|
+
if (files.length !== review_1.REQUIRED_REVIEW_EVIDENCE_FILES.length) {
|
|
2534
|
+
const missing = review_1.REQUIRED_REVIEW_EVIDENCE_FILES.filter((fileName) => !existingFiles.has(fileName));
|
|
2535
|
+
throw new Error(`missing_review_evidence:${missing.join(',')}`);
|
|
2536
|
+
}
|
|
2537
|
+
return files.map((fileName) => {
|
|
2538
|
+
const filePath = node_path_1.default.join(resolvedDir, fileName);
|
|
2539
|
+
const buffer = (0, node_fs_1.readFileSync)(filePath);
|
|
2540
|
+
if (buffer.length <= 0 || buffer.length > 10 * 1024 * 1024) {
|
|
2541
|
+
throw new Error('invalid_review_evidence_size');
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
name: fileName,
|
|
2545
|
+
contentType: contentTypeForEvidenceFile(fileName),
|
|
2546
|
+
contentBase64: buffer.toString('base64'),
|
|
2547
|
+
};
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
function resolveReviewFilePath(filePath, code) {
|
|
2551
|
+
const normalizedPath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
2552
|
+
if (!normalizedPath) {
|
|
2553
|
+
throw new Error(code);
|
|
2554
|
+
}
|
|
2555
|
+
const resolved = node_path_1.default.resolve(normalizedPath);
|
|
2556
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
2557
|
+
throw new Error(code);
|
|
2558
|
+
}
|
|
2559
|
+
return resolved;
|
|
2560
|
+
}
|
|
2561
|
+
function resolveReviewEvidenceDir(evidenceDir) {
|
|
2562
|
+
const normalizedDir = typeof evidenceDir === 'string' ? evidenceDir.trim() : '';
|
|
2563
|
+
if (!normalizedDir) {
|
|
2564
|
+
throw new Error('invalid_review_evidence_dir');
|
|
2565
|
+
}
|
|
2566
|
+
const resolved = node_path_1.default.resolve(normalizedDir);
|
|
2567
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
2568
|
+
throw new Error('invalid_review_evidence_dir');
|
|
2569
|
+
}
|
|
2570
|
+
return resolved;
|
|
2571
|
+
}
|
|
2572
|
+
async function submitReviewTask(options) {
|
|
2573
|
+
const taskContext = readTaskContextFile();
|
|
2574
|
+
if (taskContext.kind !== 'GAME_REVIEW') {
|
|
2575
|
+
throw new Error('task_submit_review_requires_game_review');
|
|
2576
|
+
}
|
|
2577
|
+
const state = normalizeReviewSubmitState(options.state);
|
|
2578
|
+
const reviewMessageFile = resolveReviewFilePath(options.messageFile, 'invalid_review_message_file');
|
|
2579
|
+
const creatorFeedbackFile = state === 'ERROR'
|
|
2580
|
+
? null
|
|
2581
|
+
: resolveReviewFilePath(options.creatorFeedbackFile, 'invalid_creator_feedback_file');
|
|
2582
|
+
const evidenceDir = resolveReviewEvidenceDir(options.evidenceDir);
|
|
2583
|
+
const reviewMessage = readRequiredTextFile(reviewMessageFile, 'invalid_review_message_file');
|
|
2584
|
+
const creatorFeedback = state === 'ERROR'
|
|
2585
|
+
? null
|
|
2586
|
+
: readOptionalTextFile(creatorFeedbackFile ?? undefined);
|
|
2587
|
+
await (0, review_1.validateGameReviewResult)({
|
|
2588
|
+
reviewState: state,
|
|
2589
|
+
reviewMessage,
|
|
2590
|
+
creatorFeedback: creatorFeedback ?? '',
|
|
2591
|
+
evidenceDir,
|
|
2592
|
+
});
|
|
2593
|
+
const evidenceFiles = readReviewEvidenceFiles(evidenceDir);
|
|
2594
|
+
const ctx = await resolveTaskCommandContext('task submit-review', options.env, taskContext);
|
|
2595
|
+
if (!ctx) {
|
|
2596
|
+
throw new Error('task_submit_review_auth_required');
|
|
2597
|
+
}
|
|
2598
|
+
await ctx.client.workerSubmitAgentTaskReview(taskContext.taskId, {
|
|
2599
|
+
taskToken: taskContext.taskToken,
|
|
2600
|
+
appVersionId: taskContext.reviewAppVersionId ?? 0,
|
|
2601
|
+
reviewState: state,
|
|
2602
|
+
reviewMessage,
|
|
2603
|
+
creatorFeedback,
|
|
2604
|
+
evidenceFiles,
|
|
2605
|
+
});
|
|
2606
|
+
(0, output_1.printSuccess)('Review submitted.');
|
|
2607
|
+
}
|
|
2608
|
+
async function failTask(options) {
|
|
2609
|
+
const message = options.message?.trim();
|
|
2610
|
+
if (!message) {
|
|
2611
|
+
(0, messages_1.printErrorWithHelp)('Task fail requires a message.', ['Example: playdrop task fail -m "Build failed"'], { command: 'task fail' });
|
|
2612
|
+
node_process_1.default.exitCode = 1;
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
const taskContext = readTaskContextFile();
|
|
2616
|
+
const ctx = await resolveTaskCommandContext('task fail', options.env, taskContext);
|
|
2617
|
+
if (!ctx) {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
await ctx.client.workerFailAgentTask(taskContext.taskId, {
|
|
2621
|
+
taskToken: taskContext.taskToken,
|
|
2622
|
+
error: message,
|
|
2623
|
+
result: { failedBy: 'agent' },
|
|
2624
|
+
});
|
|
2625
|
+
(0, output_1.printSuccess)('Task marked failed.');
|
|
2626
|
+
}
|