scene-capability-engine 3.3.14 → 3.3.16
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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/bin/scene-capability-engine.js +4 -0
- package/docs/architecture.md +2 -2
- package/docs/command-reference.md +61 -2
- package/docs/developer-guide.md +1 -1
- package/lib/adoption/adoption-strategy.js +20 -11
- package/lib/adoption/backup-manager.js +1 -0
- package/lib/adoption/detection-engine.js +1 -0
- package/lib/adoption/file-classifier.js +3 -2
- package/lib/adoption/smart-orchestrator.js +3 -1
- package/lib/adoption/strategy-selector.js +1 -0
- package/lib/adoption/template-sync.js +1 -0
- package/lib/commands/adopt.js +2 -2
- package/lib/commands/scene.js +2 -24
- package/lib/commands/studio.js +1186 -0
- package/lib/orchestrator/orchestration-engine.js +192 -4
- package/lib/templates/registry-parser.js +23 -14
- package/lib/version/version-checker.js +11 -11
- package/lib/version/version-manager.js +9 -9
- package/package.json +1 -1
- package/template/.sce/config/studio-security.json +9 -0
- package/template/.sce/hooks/{sync-tasks-on-edit.kiro.hook → sync-tasks-on-edit.sce.hook} +1 -1
- /package/template/.sce/hooks/{check-spec-on-create.kiro.hook → check-spec-on-create.sce.hook} +0 -0
- /package/template/.sce/hooks/{run-tests-on-save.kiro.hook → run-tests-on-save.sce.hook} +0 -0
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs-extra');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
|
|
7
|
+
const STUDIO_JOB_API_VERSION = 'sce.studio.job/v0.1';
|
|
8
|
+
const STAGE_ORDER = ['plan', 'generate', 'apply', 'verify', 'release'];
|
|
9
|
+
const RELEASE_CHANNELS = new Set(['dev', 'prod']);
|
|
10
|
+
const STUDIO_EVENT_API_VERSION = 'sce.studio.event/v0.1';
|
|
11
|
+
const VERIFY_PROFILES = new Set(['fast', 'standard', 'strict']);
|
|
12
|
+
const RELEASE_PROFILES = new Set(['standard', 'strict']);
|
|
13
|
+
const STUDIO_REPORTS_DIR = '.sce/reports/studio';
|
|
14
|
+
const MAX_OUTPUT_PREVIEW_LENGTH = 2000;
|
|
15
|
+
const DEFAULT_STUDIO_SECURITY_POLICY = Object.freeze({
|
|
16
|
+
enabled: false,
|
|
17
|
+
require_auth_for: ['apply', 'release', 'rollback'],
|
|
18
|
+
password_env: 'SCE_STUDIO_AUTH_PASSWORD'
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function resolveStudioPaths(projectPath = process.cwd()) {
|
|
22
|
+
const studioDir = path.join(projectPath, '.sce', 'studio');
|
|
23
|
+
return {
|
|
24
|
+
projectPath,
|
|
25
|
+
studioDir,
|
|
26
|
+
jobsDir: path.join(studioDir, 'jobs'),
|
|
27
|
+
latestFile: path.join(studioDir, 'latest-job.json'),
|
|
28
|
+
eventsDir: path.join(studioDir, 'events')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nowIso() {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeString(value) {
|
|
37
|
+
if (typeof value !== 'string') {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
return value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createJobId(prefix = 'studio') {
|
|
44
|
+
const random = crypto.randomBytes(4).toString('hex');
|
|
45
|
+
return `${prefix}-${Date.now()}-${random}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createStageState() {
|
|
49
|
+
return {
|
|
50
|
+
plan: { status: 'pending', completed_at: null, metadata: {} },
|
|
51
|
+
generate: { status: 'pending', completed_at: null, metadata: {} },
|
|
52
|
+
apply: { status: 'pending', completed_at: null, metadata: {} },
|
|
53
|
+
verify: { status: 'pending', completed_at: null, metadata: {} },
|
|
54
|
+
release: { status: 'pending', completed_at: null, metadata: {} }
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clipOutput(value) {
|
|
59
|
+
if (typeof value !== 'string') {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
if (value.length <= MAX_OUTPUT_PREVIEW_LENGTH) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return `${value.slice(0, MAX_OUTPUT_PREVIEW_LENGTH)}...[truncated]`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultCommandRunner(command, args = [], options = {}) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
const result = spawnSync(command, args, {
|
|
71
|
+
cwd: options.cwd || process.cwd(),
|
|
72
|
+
env: options.env || process.env,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
75
|
+
windowsHide: true
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
80
|
+
stdout: `${result.stdout || ''}`,
|
|
81
|
+
stderr: `${result.stderr || ''}`,
|
|
82
|
+
error: result.error ? `${result.error.message || result.error}` : null,
|
|
83
|
+
duration_ms: Date.now() - startedAt
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildCommandString(command, args = []) {
|
|
88
|
+
return [command, ...args].join(' ').trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeGateStep(step) {
|
|
92
|
+
return {
|
|
93
|
+
id: normalizeString(step.id),
|
|
94
|
+
name: normalizeString(step.name) || normalizeString(step.id),
|
|
95
|
+
command: normalizeString(step.command),
|
|
96
|
+
args: Array.isArray(step.args) ? step.args.map((item) => `${item}`) : [],
|
|
97
|
+
cwd: normalizeString(step.cwd) || null,
|
|
98
|
+
enabled: step.enabled !== false,
|
|
99
|
+
skip_reason: normalizeString(step.skip_reason),
|
|
100
|
+
required: step.required !== false
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function executeGateSteps(steps, dependencies = {}) {
|
|
105
|
+
const runner = dependencies.commandRunner || defaultCommandRunner;
|
|
106
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
107
|
+
const env = dependencies.env || process.env;
|
|
108
|
+
const failOnRequiredSkip = dependencies.failOnRequiredSkip === true;
|
|
109
|
+
|
|
110
|
+
const normalizedSteps = Array.isArray(steps) ? steps.map((step) => normalizeGateStep(step)) : [];
|
|
111
|
+
const results = [];
|
|
112
|
+
let hasFailure = false;
|
|
113
|
+
|
|
114
|
+
for (const step of normalizedSteps) {
|
|
115
|
+
if (!step.enabled) {
|
|
116
|
+
const skippedAsFailure = failOnRequiredSkip && step.required;
|
|
117
|
+
if (skippedAsFailure) {
|
|
118
|
+
hasFailure = true;
|
|
119
|
+
}
|
|
120
|
+
results.push({
|
|
121
|
+
id: step.id,
|
|
122
|
+
name: step.name,
|
|
123
|
+
status: skippedAsFailure ? 'failed' : 'skipped',
|
|
124
|
+
required: step.required,
|
|
125
|
+
command: buildCommandString(step.command, step.args),
|
|
126
|
+
skip_reason: step.skip_reason || 'disabled',
|
|
127
|
+
output: skippedAsFailure
|
|
128
|
+
? { stdout: '', stderr: '', error: 'required gate step disabled under strict profile' }
|
|
129
|
+
: undefined
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const startedAt = nowIso();
|
|
135
|
+
const raw = await Promise.resolve(runner(step.command, step.args, {
|
|
136
|
+
cwd: step.cwd || projectPath,
|
|
137
|
+
env
|
|
138
|
+
}));
|
|
139
|
+
const statusCode = Number.isInteger(raw && raw.status) ? raw.status : 1;
|
|
140
|
+
const passed = statusCode === 0;
|
|
141
|
+
const endedAt = nowIso();
|
|
142
|
+
const output = {
|
|
143
|
+
stdout: clipOutput(raw && raw.stdout ? `${raw.stdout}` : ''),
|
|
144
|
+
stderr: clipOutput(raw && raw.stderr ? `${raw.stderr}` : ''),
|
|
145
|
+
error: raw && raw.error ? `${raw.error}` : null
|
|
146
|
+
};
|
|
147
|
+
const durationMs = Number.isFinite(Number(raw && raw.duration_ms))
|
|
148
|
+
? Number(raw.duration_ms)
|
|
149
|
+
: null;
|
|
150
|
+
|
|
151
|
+
results.push({
|
|
152
|
+
id: step.id,
|
|
153
|
+
name: step.name,
|
|
154
|
+
status: passed ? 'passed' : 'failed',
|
|
155
|
+
required: step.required,
|
|
156
|
+
command: buildCommandString(step.command, step.args),
|
|
157
|
+
exit_code: statusCode,
|
|
158
|
+
started_at: startedAt,
|
|
159
|
+
completed_at: endedAt,
|
|
160
|
+
duration_ms: durationMs,
|
|
161
|
+
output
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!passed && step.required) {
|
|
165
|
+
hasFailure = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
passed: !hasFailure,
|
|
171
|
+
steps: results
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readPackageJson(projectPath, fileSystem = fs) {
|
|
176
|
+
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
177
|
+
const exists = await fileSystem.pathExists(packageJsonPath);
|
|
178
|
+
if (!exists) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
return await fileSystem.readJson(packageJsonPath);
|
|
184
|
+
} catch (_error) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeSecurityPolicy(policy) {
|
|
190
|
+
const normalized = {
|
|
191
|
+
enabled: policy && policy.enabled === true,
|
|
192
|
+
require_auth_for: Array.isArray(policy && policy.require_auth_for)
|
|
193
|
+
? policy.require_auth_for
|
|
194
|
+
.map((item) => normalizeString(item))
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
: [...DEFAULT_STUDIO_SECURITY_POLICY.require_auth_for],
|
|
197
|
+
password_env: normalizeString(policy && policy.password_env) || DEFAULT_STUDIO_SECURITY_POLICY.password_env
|
|
198
|
+
};
|
|
199
|
+
return normalized;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function loadStudioSecurityPolicy(projectPath, fileSystem = fs, env = process.env) {
|
|
203
|
+
const policyPath = path.join(projectPath, '.sce', 'config', 'studio-security.json');
|
|
204
|
+
let filePolicy = {};
|
|
205
|
+
|
|
206
|
+
if (await fileSystem.pathExists(policyPath)) {
|
|
207
|
+
try {
|
|
208
|
+
filePolicy = await fileSystem.readJson(policyPath);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new Error(`Failed to read studio security policy: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const envEnabled = `${env.SCE_STUDIO_REQUIRE_AUTH || ''}`.trim() === '1';
|
|
215
|
+
const envPasswordVar = normalizeString(env.SCE_STUDIO_PASSWORD_ENV);
|
|
216
|
+
|
|
217
|
+
return normalizeSecurityPolicy({
|
|
218
|
+
...DEFAULT_STUDIO_SECURITY_POLICY,
|
|
219
|
+
...filePolicy,
|
|
220
|
+
enabled: envEnabled || filePolicy.enabled === true,
|
|
221
|
+
password_env: envPasswordVar || filePolicy.password_env || DEFAULT_STUDIO_SECURITY_POLICY.password_env
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function ensureStudioAuthorization(action, options = {}, dependencies = {}) {
|
|
226
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
227
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
228
|
+
const env = dependencies.env || process.env;
|
|
229
|
+
const policy = await loadStudioSecurityPolicy(projectPath, fileSystem, env);
|
|
230
|
+
const requiredActions = new Set(policy.require_auth_for);
|
|
231
|
+
const requiresAuth = options.requireAuth === true || (policy.enabled && requiredActions.has(action));
|
|
232
|
+
|
|
233
|
+
if (!requiresAuth) {
|
|
234
|
+
return {
|
|
235
|
+
required: false,
|
|
236
|
+
passed: true,
|
|
237
|
+
policy
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const passwordEnv = normalizeString(policy.password_env) || DEFAULT_STUDIO_SECURITY_POLICY.password_env;
|
|
242
|
+
const expectedPassword = normalizeString(dependencies.authSecret || env[passwordEnv]);
|
|
243
|
+
if (!expectedPassword) {
|
|
244
|
+
throw new Error(`Authorization required for studio ${action}, but ${passwordEnv} is not configured`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const providedPassword = normalizeString(options.authPassword);
|
|
248
|
+
if (!providedPassword) {
|
|
249
|
+
throw new Error(`Authorization required for studio ${action}. Provide --auth-password`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (providedPassword !== expectedPassword) {
|
|
253
|
+
throw new Error(`Authorization failed for studio ${action}: invalid password`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
required: true,
|
|
258
|
+
passed: true,
|
|
259
|
+
policy,
|
|
260
|
+
password_env: passwordEnv
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function buildVerifyGateSteps(options = {}, dependencies = {}) {
|
|
265
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
266
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
267
|
+
const profile = normalizeString(options.profile) || 'standard';
|
|
268
|
+
|
|
269
|
+
if (!VERIFY_PROFILES.has(profile)) {
|
|
270
|
+
throw new Error(`Invalid verify profile "${profile}". Expected one of: ${Array.from(VERIFY_PROFILES).join(', ')}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const packageJson = await readPackageJson(projectPath, fileSystem);
|
|
274
|
+
const scripts = packageJson && packageJson.scripts ? packageJson.scripts : {};
|
|
275
|
+
const hasUnit = typeof scripts['test:unit'] === 'string';
|
|
276
|
+
const hasTest = typeof scripts.test === 'string';
|
|
277
|
+
|
|
278
|
+
const steps = [];
|
|
279
|
+
if (hasUnit || hasTest) {
|
|
280
|
+
const npmCommand = hasUnit
|
|
281
|
+
? { args: ['run', 'test:unit', '--', '--runInBand'], name: 'npm run test:unit -- --runInBand', id: 'unit-tests' }
|
|
282
|
+
: { args: ['test', '--', '--runInBand'], name: 'npm test -- --runInBand', id: 'tests' };
|
|
283
|
+
steps.push({
|
|
284
|
+
id: npmCommand.id,
|
|
285
|
+
name: npmCommand.name,
|
|
286
|
+
command: 'npm',
|
|
287
|
+
args: npmCommand.args,
|
|
288
|
+
required: true
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
steps.push({
|
|
292
|
+
id: 'tests',
|
|
293
|
+
name: 'No npm test script',
|
|
294
|
+
command: 'npm',
|
|
295
|
+
args: ['test', '--', '--runInBand'],
|
|
296
|
+
enabled: false,
|
|
297
|
+
required: profile === 'strict',
|
|
298
|
+
skip_reason: 'package.json test script not found'
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (profile === 'standard' || profile === 'strict') {
|
|
303
|
+
const governanceScript = path.join(projectPath, 'scripts', 'interactive-governance-report.js');
|
|
304
|
+
const hasGovernanceScript = await fileSystem.pathExists(governanceScript);
|
|
305
|
+
steps.push({
|
|
306
|
+
id: 'interactive-governance-report',
|
|
307
|
+
name: 'interactive-governance-report',
|
|
308
|
+
command: 'node',
|
|
309
|
+
args: ['scripts/interactive-governance-report.js', '--period', 'weekly', '--json'],
|
|
310
|
+
required: true,
|
|
311
|
+
enabled: hasGovernanceScript,
|
|
312
|
+
skip_reason: hasGovernanceScript ? '' : 'scripts/interactive-governance-report.js not found'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const handoffManifest = path.join(projectPath, 'docs', 'handoffs', 'handoff-manifest.json');
|
|
316
|
+
const hasHandoffManifest = await fileSystem.pathExists(handoffManifest);
|
|
317
|
+
steps.push({
|
|
318
|
+
id: 'scene-package-publish-batch-dry-run',
|
|
319
|
+
name: 'scene package publish-batch dry-run',
|
|
320
|
+
command: 'node',
|
|
321
|
+
args: ['bin/sce.js', 'scene', 'package-publish-batch', '--manifest', 'docs/handoffs/handoff-manifest.json', '--dry-run', '--json'],
|
|
322
|
+
required: true,
|
|
323
|
+
enabled: hasHandoffManifest,
|
|
324
|
+
skip_reason: hasHandoffManifest ? '' : 'docs/handoffs/handoff-manifest.json not found'
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return steps;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function buildReleaseGateSteps(options = {}, dependencies = {}) {
|
|
332
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
333
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
334
|
+
const profile = normalizeString(options.profile) || 'standard';
|
|
335
|
+
if (!RELEASE_PROFILES.has(profile)) {
|
|
336
|
+
throw new Error(`Invalid release profile "${profile}". Expected one of: ${Array.from(RELEASE_PROFILES).join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const steps = [];
|
|
340
|
+
steps.push({
|
|
341
|
+
id: 'npm-pack-dry-run',
|
|
342
|
+
name: 'npm pack --dry-run',
|
|
343
|
+
command: 'npm',
|
|
344
|
+
args: ['pack', '--dry-run'],
|
|
345
|
+
required: true
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const weeklySummaryPath = path.join(projectPath, '.sce', 'reports', 'release-evidence', 'release-ops-weekly-summary.json');
|
|
349
|
+
const hasWeeklySummary = await fileSystem.pathExists(weeklySummaryPath);
|
|
350
|
+
steps.push({
|
|
351
|
+
id: 'release-weekly-ops-gate',
|
|
352
|
+
name: 'release weekly ops gate',
|
|
353
|
+
command: 'node',
|
|
354
|
+
args: ['scripts/release-weekly-ops-gate.js'],
|
|
355
|
+
required: true,
|
|
356
|
+
enabled: hasWeeklySummary,
|
|
357
|
+
skip_reason: hasWeeklySummary ? '' : '.sce/reports/release-evidence/release-ops-weekly-summary.json not found'
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const releaseEvidenceDir = path.join(projectPath, '.sce', 'reports', 'release-evidence');
|
|
361
|
+
const hasReleaseEvidenceDir = await fileSystem.pathExists(releaseEvidenceDir);
|
|
362
|
+
steps.push({
|
|
363
|
+
id: 'release-asset-integrity',
|
|
364
|
+
name: 'release asset integrity',
|
|
365
|
+
command: 'node',
|
|
366
|
+
args: ['scripts/release-asset-integrity-check.js'],
|
|
367
|
+
required: true,
|
|
368
|
+
enabled: hasReleaseEvidenceDir,
|
|
369
|
+
skip_reason: hasReleaseEvidenceDir ? '' : '.sce/reports/release-evidence directory not found'
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const handoffManifest = path.join(projectPath, 'docs', 'handoffs', 'handoff-manifest.json');
|
|
373
|
+
const hasHandoffManifest = await fileSystem.pathExists(handoffManifest);
|
|
374
|
+
steps.push({
|
|
375
|
+
id: 'scene-package-publish-batch-dry-run',
|
|
376
|
+
name: 'scene package publish-batch dry-run (ontology gate)',
|
|
377
|
+
command: 'node',
|
|
378
|
+
args: [
|
|
379
|
+
'bin/sce.js',
|
|
380
|
+
'scene',
|
|
381
|
+
'package-publish-batch',
|
|
382
|
+
'--manifest',
|
|
383
|
+
'docs/handoffs/handoff-manifest.json',
|
|
384
|
+
'--dry-run',
|
|
385
|
+
'--ontology-min-average-score',
|
|
386
|
+
'70',
|
|
387
|
+
'--ontology-min-valid-rate',
|
|
388
|
+
'100',
|
|
389
|
+
'--json'
|
|
390
|
+
],
|
|
391
|
+
required: true,
|
|
392
|
+
enabled: hasHandoffManifest,
|
|
393
|
+
skip_reason: hasHandoffManifest ? '' : 'docs/handoffs/handoff-manifest.json not found'
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
steps.push({
|
|
397
|
+
id: 'handoff-capability-matrix-gate',
|
|
398
|
+
name: 'handoff capability matrix gate',
|
|
399
|
+
command: 'node',
|
|
400
|
+
args: [
|
|
401
|
+
'bin/sce.js',
|
|
402
|
+
'auto',
|
|
403
|
+
'handoff',
|
|
404
|
+
'capability-matrix',
|
|
405
|
+
'--manifest',
|
|
406
|
+
'docs/handoffs/handoff-manifest.json',
|
|
407
|
+
'--profile',
|
|
408
|
+
'moqui',
|
|
409
|
+
'--fail-on-gap',
|
|
410
|
+
'--json'
|
|
411
|
+
],
|
|
412
|
+
required: true,
|
|
413
|
+
enabled: hasHandoffManifest,
|
|
414
|
+
skip_reason: hasHandoffManifest ? '' : 'docs/handoffs/handoff-manifest.json not found'
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return steps;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function writeStudioReport(projectPath, relativePath, payload, fileSystem = fs) {
|
|
421
|
+
const absolutePath = path.join(projectPath, relativePath);
|
|
422
|
+
await fileSystem.ensureDir(path.dirname(absolutePath));
|
|
423
|
+
await fileSystem.writeJson(absolutePath, payload, { spaces: 2 });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function ensureStudioDirectories(paths, fileSystem = fs) {
|
|
427
|
+
await fileSystem.ensureDir(paths.jobsDir);
|
|
428
|
+
await fileSystem.ensureDir(paths.eventsDir);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function writeLatestJob(paths, jobId, fileSystem = fs) {
|
|
432
|
+
await fileSystem.writeJson(paths.latestFile, {
|
|
433
|
+
job_id: jobId,
|
|
434
|
+
updated_at: nowIso()
|
|
435
|
+
}, { spaces: 2 });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function readLatestJob(paths, fileSystem = fs) {
|
|
439
|
+
const exists = await fileSystem.pathExists(paths.latestFile);
|
|
440
|
+
if (!exists) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const payload = await fileSystem.readJson(paths.latestFile);
|
|
445
|
+
const jobId = normalizeString(payload.job_id);
|
|
446
|
+
return jobId || null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getJobFilePath(paths, jobId) {
|
|
450
|
+
return path.join(paths.jobsDir, `${jobId}.json`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getEventLogFilePath(paths, jobId) {
|
|
454
|
+
return path.join(paths.eventsDir, `${jobId}.jsonl`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function saveJob(paths, job, fileSystem = fs) {
|
|
458
|
+
const jobFile = getJobFilePath(paths, job.job_id);
|
|
459
|
+
await fileSystem.writeJson(jobFile, job, { spaces: 2 });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function appendStudioEvent(paths, job, eventType, metadata = {}, fileSystem = fs) {
|
|
463
|
+
const event = {
|
|
464
|
+
api_version: STUDIO_EVENT_API_VERSION,
|
|
465
|
+
event_id: `evt-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`,
|
|
466
|
+
job_id: job.job_id,
|
|
467
|
+
event_type: eventType,
|
|
468
|
+
timestamp: nowIso(),
|
|
469
|
+
metadata
|
|
470
|
+
};
|
|
471
|
+
const eventLine = `${JSON.stringify(event)}\n`;
|
|
472
|
+
const eventFile = getEventLogFilePath(paths, job.job_id);
|
|
473
|
+
await fileSystem.appendFile(eventFile, eventLine, 'utf8');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function readStudioEvents(paths, jobId, options = {}, fileSystem = fs) {
|
|
477
|
+
const { limit = 50 } = options;
|
|
478
|
+
const eventFile = getEventLogFilePath(paths, jobId);
|
|
479
|
+
const exists = await fileSystem.pathExists(eventFile);
|
|
480
|
+
if (!exists) {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const content = await fileSystem.readFile(eventFile, 'utf8');
|
|
485
|
+
const lines = content
|
|
486
|
+
.split(/\r?\n/)
|
|
487
|
+
.map((line) => line.trim())
|
|
488
|
+
.filter(Boolean);
|
|
489
|
+
|
|
490
|
+
const parsed = [];
|
|
491
|
+
for (const line of lines) {
|
|
492
|
+
try {
|
|
493
|
+
const payload = JSON.parse(line);
|
|
494
|
+
parsed.push(payload);
|
|
495
|
+
} catch (_error) {
|
|
496
|
+
// Ignore malformed lines to keep event stream robust.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (limit <= 0) {
|
|
501
|
+
return parsed;
|
|
502
|
+
}
|
|
503
|
+
return parsed.slice(-limit);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function loadJob(paths, jobId, fileSystem = fs) {
|
|
507
|
+
const jobFile = getJobFilePath(paths, jobId);
|
|
508
|
+
const exists = await fileSystem.pathExists(jobFile);
|
|
509
|
+
if (!exists) {
|
|
510
|
+
throw new Error(`Studio job not found: ${jobId}`);
|
|
511
|
+
}
|
|
512
|
+
return fileSystem.readJson(jobFile);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function resolveRequestedJobId(options, latestJobId) {
|
|
516
|
+
const requested = normalizeString(options.job);
|
|
517
|
+
if (requested) {
|
|
518
|
+
return requested;
|
|
519
|
+
}
|
|
520
|
+
return latestJobId;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function buildProgress(job) {
|
|
524
|
+
const completed = STAGE_ORDER.filter((stageName) => {
|
|
525
|
+
const stage = job.stages && job.stages[stageName];
|
|
526
|
+
return stage && stage.status === 'completed';
|
|
527
|
+
}).length;
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
completed,
|
|
531
|
+
total: STAGE_ORDER.length,
|
|
532
|
+
percent: Number(((completed / STAGE_ORDER.length) * 100).toFixed(2))
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function resolveNextAction(job) {
|
|
537
|
+
if (job.status === 'rolled_back') {
|
|
538
|
+
return 'sce studio plan --from-chat <session>';
|
|
539
|
+
}
|
|
540
|
+
if (!job.stages.plan || job.stages.plan.status !== 'completed') {
|
|
541
|
+
return `sce studio plan --from-chat <session> --job ${job.job_id}`;
|
|
542
|
+
}
|
|
543
|
+
if (!job.stages.generate || job.stages.generate.status !== 'completed') {
|
|
544
|
+
return `sce studio generate --scene <scene-id> --job ${job.job_id}`;
|
|
545
|
+
}
|
|
546
|
+
if (!job.stages.apply || job.stages.apply.status !== 'completed') {
|
|
547
|
+
const patchBundleId = job.artifacts.patch_bundle_id || '<patch-bundle-id>';
|
|
548
|
+
return `sce studio apply --patch-bundle ${patchBundleId} --job ${job.job_id}`;
|
|
549
|
+
}
|
|
550
|
+
if (!job.stages.verify || job.stages.verify.status !== 'completed') {
|
|
551
|
+
return `sce studio verify --profile standard --job ${job.job_id}`;
|
|
552
|
+
}
|
|
553
|
+
if (!job.stages.release || job.stages.release.status !== 'completed') {
|
|
554
|
+
return `sce studio release --channel dev --job ${job.job_id}`;
|
|
555
|
+
}
|
|
556
|
+
return 'complete';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function printStudioPayload(payload, options = {}) {
|
|
560
|
+
if (options.json) {
|
|
561
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
console.log(chalk.blue(`Studio job: ${payload.job_id}`));
|
|
566
|
+
console.log(` Status: ${payload.status}`);
|
|
567
|
+
console.log(` Progress: ${payload.progress.completed}/${payload.progress.total} (${payload.progress.percent}%)`);
|
|
568
|
+
console.log(` Next: ${payload.next_action}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function ensureStageCompleted(job, stageName, metadata = {}) {
|
|
572
|
+
if (!job.stages || !job.stages[stageName]) {
|
|
573
|
+
job.stages = job.stages || createStageState();
|
|
574
|
+
job.stages[stageName] = { status: 'pending', completed_at: null, metadata: {} };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
job.stages[stageName] = {
|
|
578
|
+
status: 'completed',
|
|
579
|
+
completed_at: nowIso(),
|
|
580
|
+
metadata
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function isStageCompleted(job, stageName) {
|
|
585
|
+
return Boolean(job && job.stages && job.stages[stageName] && job.stages[stageName].status === 'completed');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function ensureStagePrerequisite(job, stageName, prerequisiteStage) {
|
|
589
|
+
if (!isStageCompleted(job, prerequisiteStage)) {
|
|
590
|
+
throw new Error(`Cannot run studio ${stageName}: stage "${prerequisiteStage}" is not completed`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function ensureNotRolledBack(job, stageName) {
|
|
595
|
+
if (job.status === 'rolled_back') {
|
|
596
|
+
throw new Error(`Cannot run studio ${stageName}: job ${job.job_id} is rolled back`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function buildCommandPayload(mode, job) {
|
|
601
|
+
return {
|
|
602
|
+
mode,
|
|
603
|
+
success: true,
|
|
604
|
+
job_id: job.job_id,
|
|
605
|
+
status: job.status,
|
|
606
|
+
progress: buildProgress(job),
|
|
607
|
+
next_action: resolveNextAction(job),
|
|
608
|
+
artifacts: { ...job.artifacts }
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function runStudioPlanCommand(options = {}, dependencies = {}) {
|
|
613
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
614
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
615
|
+
const fromChat = normalizeString(options.fromChat);
|
|
616
|
+
|
|
617
|
+
if (!fromChat) {
|
|
618
|
+
throw new Error('--from-chat is required');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const paths = resolveStudioPaths(projectPath);
|
|
622
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
623
|
+
|
|
624
|
+
const jobId = normalizeString(options.job) || createJobId();
|
|
625
|
+
const now = nowIso();
|
|
626
|
+
const stages = createStageState();
|
|
627
|
+
stages.plan = {
|
|
628
|
+
status: 'completed',
|
|
629
|
+
completed_at: now,
|
|
630
|
+
metadata: {
|
|
631
|
+
from_chat: fromChat
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const job = {
|
|
636
|
+
api_version: STUDIO_JOB_API_VERSION,
|
|
637
|
+
job_id: jobId,
|
|
638
|
+
created_at: now,
|
|
639
|
+
updated_at: now,
|
|
640
|
+
status: 'planned',
|
|
641
|
+
source: {
|
|
642
|
+
from_chat: fromChat,
|
|
643
|
+
goal: normalizeString(options.goal) || null
|
|
644
|
+
},
|
|
645
|
+
scene: {
|
|
646
|
+
id: null
|
|
647
|
+
},
|
|
648
|
+
target: normalizeString(options.target) || 'default',
|
|
649
|
+
stages,
|
|
650
|
+
artifacts: {
|
|
651
|
+
patch_bundle_id: null,
|
|
652
|
+
verify_report: null,
|
|
653
|
+
release_ref: null
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
await saveJob(paths, job, fileSystem);
|
|
658
|
+
await appendStudioEvent(paths, job, 'stage.plan.completed', {
|
|
659
|
+
from_chat: fromChat,
|
|
660
|
+
target: job.target
|
|
661
|
+
}, fileSystem);
|
|
662
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
663
|
+
|
|
664
|
+
const payload = buildCommandPayload('studio-plan', job);
|
|
665
|
+
printStudioPayload(payload, options);
|
|
666
|
+
return payload;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function runStudioGenerateCommand(options = {}, dependencies = {}) {
|
|
670
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
671
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
672
|
+
const sceneId = normalizeString(options.scene);
|
|
673
|
+
if (!sceneId) {
|
|
674
|
+
throw new Error('--scene is required');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const paths = resolveStudioPaths(projectPath);
|
|
678
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
679
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
680
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
681
|
+
if (!jobId) {
|
|
682
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
686
|
+
ensureNotRolledBack(job, 'generate');
|
|
687
|
+
ensureStagePrerequisite(job, 'generate', 'plan');
|
|
688
|
+
const patchBundleId = normalizeString(options.patchBundle) || `patch-${sceneId}-${Date.now()}`;
|
|
689
|
+
|
|
690
|
+
job.scene = job.scene || {};
|
|
691
|
+
job.scene.id = sceneId;
|
|
692
|
+
job.target = normalizeString(options.target) || job.target || 'default';
|
|
693
|
+
job.status = 'generated';
|
|
694
|
+
job.artifacts = job.artifacts || {};
|
|
695
|
+
job.artifacts.patch_bundle_id = patchBundleId;
|
|
696
|
+
job.updated_at = nowIso();
|
|
697
|
+
|
|
698
|
+
ensureStageCompleted(job, 'generate', {
|
|
699
|
+
scene_id: sceneId,
|
|
700
|
+
target: job.target,
|
|
701
|
+
patch_bundle_id: patchBundleId
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
await saveJob(paths, job, fileSystem);
|
|
705
|
+
await appendStudioEvent(paths, job, 'stage.generate.completed', {
|
|
706
|
+
scene_id: sceneId,
|
|
707
|
+
target: job.target,
|
|
708
|
+
patch_bundle_id: patchBundleId
|
|
709
|
+
}, fileSystem);
|
|
710
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
711
|
+
|
|
712
|
+
const payload = buildCommandPayload('studio-generate', job);
|
|
713
|
+
printStudioPayload(payload, options);
|
|
714
|
+
return payload;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function runStudioApplyCommand(options = {}, dependencies = {}) {
|
|
718
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
719
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
720
|
+
const paths = resolveStudioPaths(projectPath);
|
|
721
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
722
|
+
|
|
723
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
724
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
725
|
+
if (!jobId) {
|
|
726
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
730
|
+
ensureNotRolledBack(job, 'apply');
|
|
731
|
+
ensureStagePrerequisite(job, 'apply', 'generate');
|
|
732
|
+
const authResult = await ensureStudioAuthorization('apply', options, {
|
|
733
|
+
projectPath,
|
|
734
|
+
fileSystem,
|
|
735
|
+
env: dependencies.env,
|
|
736
|
+
authSecret: dependencies.authSecret
|
|
737
|
+
});
|
|
738
|
+
const patchBundleId = normalizeString(options.patchBundle) || normalizeString(job.artifacts.patch_bundle_id);
|
|
739
|
+
if (!patchBundleId) {
|
|
740
|
+
throw new Error('--patch-bundle is required (or generate stage must provide one)');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
job.status = 'applied';
|
|
744
|
+
job.artifacts = job.artifacts || {};
|
|
745
|
+
job.artifacts.patch_bundle_id = patchBundleId;
|
|
746
|
+
job.updated_at = nowIso();
|
|
747
|
+
|
|
748
|
+
ensureStageCompleted(job, 'apply', {
|
|
749
|
+
patch_bundle_id: patchBundleId,
|
|
750
|
+
auth_required: authResult.required
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
await saveJob(paths, job, fileSystem);
|
|
754
|
+
await appendStudioEvent(paths, job, 'stage.apply.completed', {
|
|
755
|
+
patch_bundle_id: patchBundleId,
|
|
756
|
+
auth_required: authResult.required
|
|
757
|
+
}, fileSystem);
|
|
758
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
759
|
+
|
|
760
|
+
const payload = buildCommandPayload('studio-apply', job);
|
|
761
|
+
printStudioPayload(payload, options);
|
|
762
|
+
return payload;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function runStudioVerifyCommand(options = {}, dependencies = {}) {
|
|
766
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
767
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
768
|
+
const paths = resolveStudioPaths(projectPath);
|
|
769
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
770
|
+
|
|
771
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
772
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
773
|
+
if (!jobId) {
|
|
774
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const profile = normalizeString(options.profile) || 'standard';
|
|
778
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
779
|
+
ensureNotRolledBack(job, 'verify');
|
|
780
|
+
ensureStagePrerequisite(job, 'verify', 'apply');
|
|
781
|
+
|
|
782
|
+
const verifyReportPath = `${STUDIO_REPORTS_DIR}/verify-${job.job_id}.json`;
|
|
783
|
+
const verifyStartedAt = nowIso();
|
|
784
|
+
const gateSteps = await buildVerifyGateSteps({ profile }, {
|
|
785
|
+
projectPath,
|
|
786
|
+
fileSystem
|
|
787
|
+
});
|
|
788
|
+
const gateResult = await executeGateSteps(gateSteps, {
|
|
789
|
+
projectPath,
|
|
790
|
+
commandRunner: dependencies.commandRunner,
|
|
791
|
+
env: dependencies.env,
|
|
792
|
+
failOnRequiredSkip: profile === 'strict'
|
|
793
|
+
});
|
|
794
|
+
const verifyCompletedAt = nowIso();
|
|
795
|
+
const verifyReport = {
|
|
796
|
+
mode: 'studio-verify',
|
|
797
|
+
api_version: STUDIO_JOB_API_VERSION,
|
|
798
|
+
job_id: job.job_id,
|
|
799
|
+
profile,
|
|
800
|
+
started_at: verifyStartedAt,
|
|
801
|
+
completed_at: verifyCompletedAt,
|
|
802
|
+
passed: gateResult.passed,
|
|
803
|
+
steps: gateResult.steps
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
await writeStudioReport(projectPath, verifyReportPath, verifyReport, fileSystem);
|
|
807
|
+
|
|
808
|
+
job.artifacts = job.artifacts || {};
|
|
809
|
+
job.artifacts.verify_report = verifyReportPath;
|
|
810
|
+
job.updated_at = verifyCompletedAt;
|
|
811
|
+
|
|
812
|
+
if (!gateResult.passed) {
|
|
813
|
+
job.status = 'verify_failed';
|
|
814
|
+
job.stages.verify = {
|
|
815
|
+
status: 'failed',
|
|
816
|
+
completed_at: null,
|
|
817
|
+
metadata: {
|
|
818
|
+
profile,
|
|
819
|
+
passed: false,
|
|
820
|
+
report: verifyReportPath
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
await saveJob(paths, job, fileSystem);
|
|
824
|
+
await appendStudioEvent(paths, job, 'stage.verify.failed', {
|
|
825
|
+
profile,
|
|
826
|
+
report: verifyReportPath
|
|
827
|
+
}, fileSystem);
|
|
828
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
829
|
+
throw new Error(`studio verify failed: ${gateResult.steps.filter((step) => step.status === 'failed').map((step) => step.id).join(', ')}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
job.status = 'verified';
|
|
833
|
+
ensureStageCompleted(job, 'verify', {
|
|
834
|
+
profile,
|
|
835
|
+
passed: true,
|
|
836
|
+
report: verifyReportPath
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
await saveJob(paths, job, fileSystem);
|
|
840
|
+
await appendStudioEvent(paths, job, 'stage.verify.completed', {
|
|
841
|
+
profile,
|
|
842
|
+
passed: true,
|
|
843
|
+
report: verifyReportPath
|
|
844
|
+
}, fileSystem);
|
|
845
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
846
|
+
|
|
847
|
+
const payload = buildCommandPayload('studio-verify', job);
|
|
848
|
+
printStudioPayload(payload, options);
|
|
849
|
+
return payload;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function runStudioReleaseCommand(options = {}, dependencies = {}) {
|
|
853
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
854
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
855
|
+
const paths = resolveStudioPaths(projectPath);
|
|
856
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
857
|
+
|
|
858
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
859
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
860
|
+
if (!jobId) {
|
|
861
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const channel = normalizeString(options.channel) || 'dev';
|
|
865
|
+
if (!RELEASE_CHANNELS.has(channel)) {
|
|
866
|
+
throw new Error(`Invalid --channel "${channel}". Expected one of: ${Array.from(RELEASE_CHANNELS).join(', ')}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
870
|
+
ensureNotRolledBack(job, 'release');
|
|
871
|
+
ensureStagePrerequisite(job, 'release', 'verify');
|
|
872
|
+
const authResult = await ensureStudioAuthorization('release', options, {
|
|
873
|
+
projectPath,
|
|
874
|
+
fileSystem,
|
|
875
|
+
env: dependencies.env,
|
|
876
|
+
authSecret: dependencies.authSecret
|
|
877
|
+
});
|
|
878
|
+
const releaseRef = normalizeString(options.releaseRef) || `${channel}-${Date.now()}`;
|
|
879
|
+
|
|
880
|
+
const profile = normalizeString(options.profile) || 'standard';
|
|
881
|
+
const releaseReportPath = `${STUDIO_REPORTS_DIR}/release-${job.job_id}.json`;
|
|
882
|
+
const releaseStartedAt = nowIso();
|
|
883
|
+
const gateSteps = await buildReleaseGateSteps({ profile }, {
|
|
884
|
+
projectPath,
|
|
885
|
+
fileSystem
|
|
886
|
+
});
|
|
887
|
+
const gateResult = await executeGateSteps(gateSteps, {
|
|
888
|
+
projectPath,
|
|
889
|
+
commandRunner: dependencies.commandRunner,
|
|
890
|
+
env: dependencies.env,
|
|
891
|
+
failOnRequiredSkip: profile === 'strict'
|
|
892
|
+
});
|
|
893
|
+
const releaseCompletedAt = nowIso();
|
|
894
|
+
const releaseReport = {
|
|
895
|
+
mode: 'studio-release',
|
|
896
|
+
api_version: STUDIO_JOB_API_VERSION,
|
|
897
|
+
job_id: job.job_id,
|
|
898
|
+
profile,
|
|
899
|
+
channel,
|
|
900
|
+
release_ref: releaseRef,
|
|
901
|
+
started_at: releaseStartedAt,
|
|
902
|
+
completed_at: releaseCompletedAt,
|
|
903
|
+
passed: gateResult.passed,
|
|
904
|
+
steps: gateResult.steps
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
await writeStudioReport(projectPath, releaseReportPath, releaseReport, fileSystem);
|
|
908
|
+
|
|
909
|
+
job.artifacts = job.artifacts || {};
|
|
910
|
+
job.artifacts.release_ref = releaseRef;
|
|
911
|
+
job.artifacts.release_report = releaseReportPath;
|
|
912
|
+
job.updated_at = releaseCompletedAt;
|
|
913
|
+
|
|
914
|
+
if (!gateResult.passed) {
|
|
915
|
+
job.status = 'release_failed';
|
|
916
|
+
job.stages.release = {
|
|
917
|
+
status: 'failed',
|
|
918
|
+
completed_at: null,
|
|
919
|
+
metadata: {
|
|
920
|
+
channel,
|
|
921
|
+
release_ref: releaseRef,
|
|
922
|
+
passed: false,
|
|
923
|
+
report: releaseReportPath,
|
|
924
|
+
auth_required: authResult.required
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
await saveJob(paths, job, fileSystem);
|
|
928
|
+
await appendStudioEvent(paths, job, 'stage.release.failed', {
|
|
929
|
+
channel,
|
|
930
|
+
release_ref: releaseRef,
|
|
931
|
+
report: releaseReportPath,
|
|
932
|
+
auth_required: authResult.required
|
|
933
|
+
}, fileSystem);
|
|
934
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
935
|
+
throw new Error(`studio release failed: ${gateResult.steps.filter((step) => step.status === 'failed').map((step) => step.id).join(', ')}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
job.status = 'released';
|
|
939
|
+
ensureStageCompleted(job, 'release', {
|
|
940
|
+
channel,
|
|
941
|
+
release_ref: releaseRef,
|
|
942
|
+
report: releaseReportPath,
|
|
943
|
+
auth_required: authResult.required
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await saveJob(paths, job, fileSystem);
|
|
947
|
+
await appendStudioEvent(paths, job, 'stage.release.completed', {
|
|
948
|
+
channel,
|
|
949
|
+
release_ref: releaseRef,
|
|
950
|
+
report: releaseReportPath,
|
|
951
|
+
auth_required: authResult.required
|
|
952
|
+
}, fileSystem);
|
|
953
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
954
|
+
|
|
955
|
+
const payload = buildCommandPayload('studio-release', job);
|
|
956
|
+
printStudioPayload(payload, options);
|
|
957
|
+
return payload;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function runStudioResumeCommand(options = {}, dependencies = {}) {
|
|
961
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
962
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
963
|
+
const paths = resolveStudioPaths(projectPath);
|
|
964
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
965
|
+
|
|
966
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
967
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
968
|
+
if (!jobId) {
|
|
969
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
973
|
+
const payload = buildCommandPayload('studio-resume', job);
|
|
974
|
+
payload.success = true;
|
|
975
|
+
printStudioPayload(payload, options);
|
|
976
|
+
return payload;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function runStudioRollbackCommand(options = {}, dependencies = {}) {
|
|
980
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
981
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
982
|
+
const paths = resolveStudioPaths(projectPath);
|
|
983
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
984
|
+
|
|
985
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
986
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
987
|
+
if (!jobId) {
|
|
988
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const reason = normalizeString(options.reason) || 'manual-rollback';
|
|
992
|
+
const job = await loadJob(paths, jobId, fileSystem);
|
|
993
|
+
const authResult = await ensureStudioAuthorization('rollback', options, {
|
|
994
|
+
projectPath,
|
|
995
|
+
fileSystem,
|
|
996
|
+
env: dependencies.env,
|
|
997
|
+
authSecret: dependencies.authSecret
|
|
998
|
+
});
|
|
999
|
+
if (!isStageCompleted(job, 'apply')) {
|
|
1000
|
+
throw new Error(`Cannot rollback studio job ${job.job_id}: apply stage is not completed`);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
job.status = 'rolled_back';
|
|
1004
|
+
job.updated_at = nowIso();
|
|
1005
|
+
job.rollback = {
|
|
1006
|
+
reason,
|
|
1007
|
+
rolled_back_at: job.updated_at,
|
|
1008
|
+
auth_required: authResult.required
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
await saveJob(paths, job, fileSystem);
|
|
1012
|
+
await appendStudioEvent(paths, job, 'job.rolled_back', {
|
|
1013
|
+
reason
|
|
1014
|
+
}, fileSystem);
|
|
1015
|
+
await writeLatestJob(paths, jobId, fileSystem);
|
|
1016
|
+
|
|
1017
|
+
const payload = buildCommandPayload('studio-rollback', job);
|
|
1018
|
+
payload.rollback = { ...job.rollback };
|
|
1019
|
+
printStudioPayload(payload, options);
|
|
1020
|
+
return payload;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function normalizePositiveInteger(value, fallback) {
|
|
1024
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
1025
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1026
|
+
return fallback;
|
|
1027
|
+
}
|
|
1028
|
+
return parsed;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function printStudioEventsPayload(payload, options = {}) {
|
|
1032
|
+
if (options.json) {
|
|
1033
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
console.log(chalk.blue(`Studio events: ${payload.job_id}`));
|
|
1038
|
+
console.log(` Count: ${payload.events.length}`);
|
|
1039
|
+
for (const event of payload.events) {
|
|
1040
|
+
console.log(` - ${event.timestamp} ${event.event_type}`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function runStudioEventsCommand(options = {}, dependencies = {}) {
|
|
1045
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
1046
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
1047
|
+
const paths = resolveStudioPaths(projectPath);
|
|
1048
|
+
await ensureStudioDirectories(paths, fileSystem);
|
|
1049
|
+
|
|
1050
|
+
const latestJobId = await readLatestJob(paths, fileSystem);
|
|
1051
|
+
const jobId = resolveRequestedJobId(options, latestJobId);
|
|
1052
|
+
if (!jobId) {
|
|
1053
|
+
throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const limit = normalizePositiveInteger(options.limit, 50);
|
|
1057
|
+
const events = await readStudioEvents(paths, jobId, { limit }, fileSystem);
|
|
1058
|
+
|
|
1059
|
+
const payload = {
|
|
1060
|
+
mode: 'studio-events',
|
|
1061
|
+
success: true,
|
|
1062
|
+
job_id: jobId,
|
|
1063
|
+
limit,
|
|
1064
|
+
events
|
|
1065
|
+
};
|
|
1066
|
+
printStudioEventsPayload(payload, options);
|
|
1067
|
+
return payload;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function runStudioCommand(handler, options) {
|
|
1071
|
+
try {
|
|
1072
|
+
await handler(options);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
console.error(chalk.red(`Studio command failed: ${error.message}`));
|
|
1075
|
+
process.exitCode = 1;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function registerStudioCommands(program) {
|
|
1080
|
+
const studio = program
|
|
1081
|
+
.command('studio')
|
|
1082
|
+
.description('Run studio chat-to-release orchestration workflow');
|
|
1083
|
+
|
|
1084
|
+
studio
|
|
1085
|
+
.command('plan')
|
|
1086
|
+
.description('Create/refresh a studio plan job from chat context')
|
|
1087
|
+
.requiredOption('--from-chat <session>', 'Chat session identifier or transcript reference')
|
|
1088
|
+
.option('--goal <goal>', 'Optional goal summary')
|
|
1089
|
+
.option('--target <target>', 'Target integration profile', 'default')
|
|
1090
|
+
.option('--job <job-id>', 'Reuse an explicit studio job id')
|
|
1091
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1092
|
+
.action(async (options) => runStudioCommand(runStudioPlanCommand, options));
|
|
1093
|
+
|
|
1094
|
+
studio
|
|
1095
|
+
.command('generate')
|
|
1096
|
+
.description('Generate patch bundle metadata for a planned studio job')
|
|
1097
|
+
.requiredOption('--scene <scene-id>', 'Scene identifier to generate')
|
|
1098
|
+
.option('--target <target>', 'Target integration profile override')
|
|
1099
|
+
.option('--patch-bundle <id>', 'Explicit patch bundle id')
|
|
1100
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1101
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1102
|
+
.action(async (options) => runStudioCommand(runStudioGenerateCommand, options));
|
|
1103
|
+
|
|
1104
|
+
studio
|
|
1105
|
+
.command('apply')
|
|
1106
|
+
.description('Apply generated patch bundle metadata to studio job')
|
|
1107
|
+
.option('--patch-bundle <id>', 'Patch bundle identifier (defaults to generated artifact)')
|
|
1108
|
+
.option('--auth-password <password>', 'Authorization password for protected apply action')
|
|
1109
|
+
.option('--require-auth', 'Require authorization even when policy is advisory')
|
|
1110
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1111
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1112
|
+
.action(async (options) => runStudioCommand(runStudioApplyCommand, options));
|
|
1113
|
+
|
|
1114
|
+
studio
|
|
1115
|
+
.command('verify')
|
|
1116
|
+
.description('Record verification stage for studio job')
|
|
1117
|
+
.option('--profile <profile>', 'Verification profile', 'standard')
|
|
1118
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1119
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1120
|
+
.action(async (options) => runStudioCommand(runStudioVerifyCommand, options));
|
|
1121
|
+
|
|
1122
|
+
studio
|
|
1123
|
+
.command('release')
|
|
1124
|
+
.description('Record release stage for studio job')
|
|
1125
|
+
.option('--channel <channel>', 'Release channel (dev|prod)', 'dev')
|
|
1126
|
+
.option('--profile <profile>', 'Release gate profile', 'standard')
|
|
1127
|
+
.option('--auth-password <password>', 'Authorization password for protected release action')
|
|
1128
|
+
.option('--require-auth', 'Require authorization even when policy is advisory')
|
|
1129
|
+
.option('--release-ref <ref>', 'Explicit release reference/tag')
|
|
1130
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1131
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1132
|
+
.action(async (options) => runStudioCommand(runStudioReleaseCommand, options));
|
|
1133
|
+
|
|
1134
|
+
studio
|
|
1135
|
+
.command('resume')
|
|
1136
|
+
.description('Inspect current studio job and next action')
|
|
1137
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1138
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1139
|
+
.action(async (options) => runStudioCommand(runStudioResumeCommand, options));
|
|
1140
|
+
|
|
1141
|
+
studio
|
|
1142
|
+
.command('events')
|
|
1143
|
+
.description('Show studio job event stream')
|
|
1144
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1145
|
+
.option('--limit <number>', 'Maximum number of recent events to return', '50')
|
|
1146
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1147
|
+
.action(async (options) => runStudioCommand(runStudioEventsCommand, options));
|
|
1148
|
+
|
|
1149
|
+
studio
|
|
1150
|
+
.command('rollback')
|
|
1151
|
+
.description('Rollback a studio job after apply/release')
|
|
1152
|
+
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1153
|
+
.option('--reason <reason>', 'Rollback reason')
|
|
1154
|
+
.option('--auth-password <password>', 'Authorization password for protected rollback action')
|
|
1155
|
+
.option('--require-auth', 'Require authorization even when policy is advisory')
|
|
1156
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
1157
|
+
.action(async (options) => runStudioCommand(runStudioRollbackCommand, options));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
module.exports = {
|
|
1161
|
+
STUDIO_JOB_API_VERSION,
|
|
1162
|
+
STUDIO_EVENT_API_VERSION,
|
|
1163
|
+
STAGE_ORDER,
|
|
1164
|
+
RELEASE_CHANNELS,
|
|
1165
|
+
resolveStudioPaths,
|
|
1166
|
+
createJobId,
|
|
1167
|
+
createStageState,
|
|
1168
|
+
readStudioEvents,
|
|
1169
|
+
readLatestJob,
|
|
1170
|
+
executeGateSteps,
|
|
1171
|
+
loadStudioSecurityPolicy,
|
|
1172
|
+
ensureStudioAuthorization,
|
|
1173
|
+
buildVerifyGateSteps,
|
|
1174
|
+
buildReleaseGateSteps,
|
|
1175
|
+
resolveNextAction,
|
|
1176
|
+
buildProgress,
|
|
1177
|
+
runStudioPlanCommand,
|
|
1178
|
+
runStudioGenerateCommand,
|
|
1179
|
+
runStudioApplyCommand,
|
|
1180
|
+
runStudioVerifyCommand,
|
|
1181
|
+
runStudioReleaseCommand,
|
|
1182
|
+
runStudioRollbackCommand,
|
|
1183
|
+
runStudioEventsCommand,
|
|
1184
|
+
runStudioResumeCommand,
|
|
1185
|
+
registerStudioCommands
|
|
1186
|
+
};
|