|
|
@@ -0,0 +1,1291 @@
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import { spawn } from 'node:child_process';
|
|
|
+import { createHash } from 'node:crypto';
|
|
|
+import fs from 'node:fs';
|
|
|
+import os from 'node:os';
|
|
|
+import path from 'node:path';
|
|
|
+import test, { after } from 'node:test';
|
|
|
+import { pathToFileURL } from 'node:url';
|
|
|
+import { createRequestKey, sha256, stableStringify } from '../../core/request-key.mjs';
|
|
|
+import { createReadableRunId, resolveRunDirectory } from '../../core/run-paths.mjs';
|
|
|
+import { readState, writeState } from '../../core/state-store.mjs';
|
|
|
+import { GEMINI_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
|
|
|
+import { ProviderError } from '../../providers/provider-error.mjs';
|
|
|
+import { buildImageSetPlan, toPublicPlan } from '../planning/planner.mjs';
|
|
|
+import { runEdit } from '../../tools/edit.mjs';
|
|
|
+import { runGenerate } from '../../tools/generate.mjs';
|
|
|
+import { buildOperationPlan, executePlan, refreshRun } from './orchestrator.mjs';
|
|
|
+
|
|
|
+const packageRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
|
|
|
+const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-image-set-orchestrator-'));
|
|
|
+
|
|
|
+function removeTree(target) {
|
|
|
+ if (!fs.existsSync(target)) return;
|
|
|
+ const stat = fs.lstatSync(target);
|
|
|
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return fs.unlinkSync(target);
|
|
|
+ for (const entry of fs.readdirSync(target)) removeTree(path.join(target, entry));
|
|
|
+ fs.rmdirSync(target);
|
|
|
+}
|
|
|
+
|
|
|
+after(() => removeTree(workspaceRoot));
|
|
|
+
|
|
|
+class FakeImageProvider {
|
|
|
+ constructor(options = {}) { this.generations = 0; this.unknownAt = options.unknownAt || 0; this.references = []; }
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ this.references.push([...(item.referenceImages || [])]);
|
|
|
+ if (this.generations === this.unknownAt) throw new ProviderError('submission_unknown', 'ambiguous');
|
|
|
+ return { requestId: `request-${this.generations}`, model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ }
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function fakePng(width, height, colorType = 2) {
|
|
|
+ const bytes = Buffer.alloc(33);
|
|
|
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes, 0);
|
|
|
+ bytes.writeUInt32BE(13, 8);
|
|
|
+ bytes.write('IHDR', 12, 'ascii');
|
|
|
+ bytes.writeUInt32BE(width, 16);
|
|
|
+ bytes.writeUInt32BE(height, 20);
|
|
|
+ bytes[24] = 8;
|
|
|
+ bytes[25] = colorType;
|
|
|
+ return bytes;
|
|
|
+}
|
|
|
+
|
|
|
+class DomesticImageProvider extends FakeImageProvider {
|
|
|
+ constructor(width, height) { super(); this.width = width; this.height = height; }
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, fakePng(this.width, this.height), { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const passingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } }) };
|
|
|
+
|
|
|
+function rehashPlan(plan) {
|
|
|
+ const { planId, planHash, createdAt, confirmationMessage, ...core } = plan;
|
|
|
+ return { ...plan, planHash: sha256(stableStringify(core)) };
|
|
|
+}
|
|
|
+
|
|
|
+test('generation requires explicit confirmation, accepts omitted budget, and keeps the legacy budget gate', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'test' });
|
|
|
+ const noConfirm = await executePlan({ plan, sessionId: 'session-budget', promptId: 'prompt-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
|
|
|
+ assert.equal(noConfirm.status, 'needs_confirmation');
|
|
|
+ const lowBudget = await executePlan({ plan, confirmed: true, budgetLimitCny: 0.01, sessionId: 'session-budget', promptId: 'prompt-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
|
|
|
+ assert.equal(lowBudget.status, 'needs_confirmation');
|
|
|
+ const noBudget = await executePlan({ plan, confirmed: true, sessionId: `session-no-budget-${Date.now()}`, promptId: 'prompt-no-budget', workspaceRoot, allowClaudeAttachments: false }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try { assert.equal(noBudget.status, 'ok'); }
|
|
|
+ finally { if (noBudget.files[0]) removeTree(path.dirname(noBudget.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('budget gate recomputes cost from trusted model reserve', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'tampered budget' });
|
|
|
+ plan.items[0].estimatedCostCny = 0;
|
|
|
+ plan.estimatedCostCny = 0;
|
|
|
+ const forged = rehashPlan(plan);
|
|
|
+ const result = await executePlan({ plan: forged, confirmed: true, budgetLimitCny: 0.01, sessionId: 'session-forged-budget', promptId: 'prompt-forged-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
|
|
|
+ assert.equal(result.status, 'needs_confirmation');
|
|
|
+ assert.doesNotMatch(result.assistantMessage, /0\.75|¥|预算|费用/);
|
|
|
+});
|
|
|
+
|
|
|
+test('same request is idempotent and does not generate twice', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'idempotent test' });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const input = { plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-${Date.now()}`, promptId: 'prompt-idem', workspaceRoot, allowClaudeAttachments: false };
|
|
|
+ const first = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const second = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(first.status, 'ok');
|
|
|
+ assert.equal(second.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.match(second.assistantMessage, /未重复提交|已经完成/);
|
|
|
+ } finally { if (first.files[0]) removeTree(path.dirname(first.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('run-level lock prevents two simultaneous initial executions from generating twice', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'simultaneous initial execution', productName: '初始并发锁商品' });
|
|
|
+ let releaseGeneration;
|
|
|
+ let signalStarted;
|
|
|
+ const started = new Promise(resolve => { signalStarted = resolve; });
|
|
|
+ const gate = new Promise(resolve => { releaseGeneration = resolve; });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ provider.generate = async item => {
|
|
|
+ provider.generations += 1;
|
|
|
+ signalStarted();
|
|
|
+ await gate;
|
|
|
+ return { requestId: 'initial-lock-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ };
|
|
|
+ const input = {
|
|
|
+ plan, confirmed: true, sessionId: `session-initial-lock-${Date.now()}`,
|
|
|
+ promptId: 'prompt-initial-lock', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ };
|
|
|
+ const firstPromise = executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ await started;
|
|
|
+ const concurrent = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ releaseGeneration();
|
|
|
+ const first = await firstPromise;
|
|
|
+ try {
|
|
|
+ assert.equal(concurrent.status, 'blocked');
|
|
|
+ assert.equal(concurrent.data.run.items[0].status, 'submitting');
|
|
|
+ assert.equal(first.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ } finally { if (first.files[0]) removeTree(path.dirname(first.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('two processes cannot both take over the same stale run lock', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'cross-process stale run lock', productName: '跨进程陈旧锁商品' });
|
|
|
+ const failingProvider = new FakeImageProvider();
|
|
|
+ failingProvider.generate = async () => { throw new ProviderError('submission_unknown', 'ambiguous'); };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan, confirmed: true, sessionId: `session-stale-lock-source-${Date.now()}`,
|
|
|
+ promptId: 'prompt-stale-lock-source', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: failingProvider, visionProvider: passingVision, workspaceRoot, maxGenerationAttempts: 1, retryDelaysMs: [], sleep: async () => {} });
|
|
|
+ const runDirectory = resolveRunDirectory({ workspaceRoot, packageRoot, runId: initial.data.run.runId }).runDirectory;
|
|
|
+ const lockPath = path.join(runDirectory, '.generation.lock');
|
|
|
+ fs.writeFileSync(lockPath, `${JSON.stringify({ pid: 2147483647, requestKey: 'stale-owner', createdAt: new Date(0).toISOString() })}\n`, { flag: 'wx' });
|
|
|
+ const runnerPath = path.join(workspaceRoot, `stale-lock-runner-${Date.now()}.mjs`);
|
|
|
+ const generateUrl = pathToFileURL(path.join(packageRoot, 'mcp', 'src', 'tools', 'generate.mjs')).href;
|
|
|
+ fs.writeFileSync(runnerPath, `
|
|
|
+import fs from 'node:fs';
|
|
|
+import path from 'node:path';
|
|
|
+import { runGenerate } from ${JSON.stringify(generateUrl)};
|
|
|
+const [workspaceRoot, runId, startAt] = process.argv.slice(2);
|
|
|
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
+while (Date.now() < Number(startAt)) await wait(5);
|
|
|
+const provider = {
|
|
|
+ generations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ await wait(1500);
|
|
|
+ return { requestId: 'stale-takeover', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ const file = path.join(runDirectory, outputId + '-1.png');
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+};
|
|
|
+const visionProvider = { review: async () => ({ model: 'test', review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } }) };
|
|
|
+const result = await runGenerate({
|
|
|
+ runId, retryOutputIds: ['O1'], confirmed: true,
|
|
|
+ sessionId: 'cross-process-stale-session', promptId: 'cross-process-stale-prompt'
|
|
|
+}, { provider, visionProvider, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+process.stdout.write(JSON.stringify({ status: result.status, generations: provider.generations }));
|
|
|
+`);
|
|
|
+ const startAt = Date.now() + 500;
|
|
|
+ const runChild = () => new Promise((resolve, reject) => {
|
|
|
+ const child = spawn(process.execPath, [runnerPath, workspaceRoot, initial.data.run.runId, String(startAt)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
+ let stdout = '';
|
|
|
+ let stderr = '';
|
|
|
+ child.stdout.on('data', chunk => { stdout += chunk; });
|
|
|
+ child.stderr.on('data', chunk => { stderr += chunk; });
|
|
|
+ child.once('error', reject);
|
|
|
+ child.once('exit', code => {
|
|
|
+ if (code !== 0) return reject(new Error(`stale lock child failed (${code}): ${stderr}`));
|
|
|
+ try { resolve(JSON.parse(stdout)); } catch (error) { reject(new Error(`invalid stale lock child output: ${stdout}; ${error.message}`)); }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ const results = await Promise.all([runChild(), runChild()]);
|
|
|
+ assert.deepEqual(results.map(result => result.status).sort(), ['blocked', 'ok']);
|
|
|
+ assert.equal(results.reduce((sum, result) => sum + result.generations, 0), 1);
|
|
|
+ assert.equal(fs.existsSync(path.join(runDirectory, '.generation.lock.claim')), false);
|
|
|
+ assert.equal(readState(runDirectory).items[0].status, 'completed');
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(runnerPath)) fs.unlinkSync(runnerPath);
|
|
|
+ removeTree(runDirectory);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('first completed local image becomes a data URL identity anchor', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '两步锚点测试', preset: 'compact' });
|
|
|
+ plan.items = plan.items.slice(0, 2);
|
|
|
+ plan.estimatedCostCny = 1.496;
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const result = await executePlan({ plan: shortened, confirmed: true, budgetLimitCny: 2, sessionId: `session-anchor-${Date.now()}`, promptId: 'prompt-anchor', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 2);
|
|
|
+ assert.equal(provider.references[0].length, 0);
|
|
|
+ assert.ok(provider.references[1].some(value => /^data:image\/png;base64,/.test(value)));
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('submission_unknown is retried with one stable idempotency key and later outputs continue', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '未知提交停止测试', preset: 'compact' });
|
|
|
+ const provider = new FakeImageProvider({ unknownAt: 2 });
|
|
|
+ const keys = [];
|
|
|
+ const originalGenerate = provider.generate.bind(provider);
|
|
|
+ provider.generate = async item => { keys.push({ outputId: item.outputId, key: item.idempotencyKey }); return originalGenerate(item); };
|
|
|
+ const result = await executePlan({ plan, confirmed: true, sessionId: `session-unknown-${Date.now()}`, promptId: 'prompt-unknown', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(provider.generations, plan.items.length + 1);
|
|
|
+ assert.ok(result.data.run.items.every(item => item.status === 'completed'));
|
|
|
+ const retried = keys.filter(entry => entry.outputId === plan.items[1].outputId);
|
|
|
+ assert.equal(retried.length, 2);
|
|
|
+ assert.equal(retried[0].key, retried[1].key);
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('retry exhaustion records only the failed output and does not block later outputs', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '失败继续测试', preset: 'compact' });
|
|
|
+ plan.items = plan.items.slice(0, 3);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const calls = [];
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const originalGenerate = provider.generate.bind(provider);
|
|
|
+ provider.generate = async item => {
|
|
|
+ calls.push(item.outputId);
|
|
|
+ if (item.outputId === shortened.items[1].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
|
|
|
+ return originalGenerate(item);
|
|
|
+ };
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, sessionId: `session-exhaust-${Date.now()}`,
|
|
|
+ promptId: 'prompt-exhaust', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'partial');
|
|
|
+ assert.deepEqual(result.summary.failedOutputIds, [shortened.items[1].outputId]);
|
|
|
+ assert.equal(result.data.run.items[0].status, 'completed');
|
|
|
+ assert.equal(result.data.run.items[1].status, 'submission_unknown');
|
|
|
+ assert.equal(result.data.run.items[2].status, 'completed');
|
|
|
+ assert.equal(calls.filter(id => id === shortened.items[1].outputId).length, 3);
|
|
|
+ assert.match(result.nextActions[0], new RegExp(shortened.items[1].outputId));
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('same-run retry regenerates only selected failed outputIds and is idempotent', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '原任务指定项续跑', preset: 'compact', productName: '指定项续跑商品' });
|
|
|
+ plan.items = plan.items.slice(0, 3);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const initialProvider = new FakeImageProvider();
|
|
|
+ const initialGenerate = initialProvider.generate.bind(initialProvider);
|
|
|
+ initialProvider.generate = async item => {
|
|
|
+ if (item.outputId === shortened.items[1].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
|
|
|
+ return initialGenerate(item);
|
|
|
+ };
|
|
|
+ const initial = await runGenerate({
|
|
|
+ plan: shortened, confirmed: true, sessionId: `session-target-initial-${Date.now()}`,
|
|
|
+ promptId: 'prompt-target-initial', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: initialProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ const before = initial.data.run.items.map(item => ({ outputId: item.outputId, requestId: item.requestId, files: [...item.files] }));
|
|
|
+ const retryProvider = new FakeImageProvider();
|
|
|
+ const retryInput = {
|
|
|
+ runId: initial.data.run.runId, retryOutputIds: [shortened.items[1].outputId], confirmed: true,
|
|
|
+ sessionId: `session-target-retry-${Date.now()}`, promptId: 'prompt-target-retry'
|
|
|
+ };
|
|
|
+ const runDirectory = path.dirname(initial.files[0]);
|
|
|
+ const lockPath = path.join(runDirectory, `.revision-${shortened.items[1].outputId}.lock`);
|
|
|
+ const lockOwner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' });
|
|
|
+ await new Promise(resolve => lockOwner.once('spawn', resolve));
|
|
|
+ fs.writeFileSync(lockPath, `${JSON.stringify({ pid: lockOwner.pid, requestKey: 'active-test', createdAt: new Date().toISOString() })}\n`, { flag: 'wx' });
|
|
|
+ const blocked = await runGenerate(retryInput, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ lockOwner.kill();
|
|
|
+ if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
|
+ const retried = await runGenerate(retryInput, {
|
|
|
+ provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {}
|
|
|
+ });
|
|
|
+ const repeated = await runGenerate(retryInput, {
|
|
|
+ provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {}
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ assert.equal(initial.status, 'partial');
|
|
|
+ assert.equal(blocked.status, 'blocked');
|
|
|
+ assert.equal(blocked.data.run.items[1].status, 'submission_unknown');
|
|
|
+ assert.equal(retried.status, 'ok', JSON.stringify(retried));
|
|
|
+ assert.equal(retried.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(retryProvider.generations, 1);
|
|
|
+ assert.equal(repeated.status, 'ok');
|
|
|
+ assert.equal(retryProvider.generations, 1);
|
|
|
+ for (const index of [0, 2]) {
|
|
|
+ assert.equal(retried.data.run.items[index].requestId, before[index].requestId);
|
|
|
+ assert.deepEqual(retried.data.run.items[index].files, before[index].files);
|
|
|
+ }
|
|
|
+ assert.equal(retried.data.run.items[1].status, 'completed');
|
|
|
+ assert.equal(retried.data.run.items[1].retryHistory.length, 1);
|
|
|
+ } finally {
|
|
|
+ const firstFile = retried.files[0] || initial.files[0];
|
|
|
+ if (firstFile) removeTree(path.dirname(firstFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('run-level lock serializes simultaneous retries and keeps status from rewriting active state', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'concurrent retry run lock', productName: '续跑并发锁商品' });
|
|
|
+ const failingProvider = new FakeImageProvider();
|
|
|
+ failingProvider.generate = async () => { throw new ProviderError('submission_unknown', 'ambiguous'); };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan, confirmed: true, sessionId: `session-concurrent-retry-source-${Date.now()}`,
|
|
|
+ promptId: 'prompt-concurrent-retry-source', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: failingProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ let releaseGeneration;
|
|
|
+ let signalStarted;
|
|
|
+ const started = new Promise(resolve => { signalStarted = resolve; });
|
|
|
+ const gate = new Promise(resolve => { releaseGeneration = resolve; });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ provider.generate = async item => {
|
|
|
+ provider.generations += 1;
|
|
|
+ signalStarted();
|
|
|
+ await gate;
|
|
|
+ return { requestId: 'concurrent-retry-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ };
|
|
|
+ const retryInput = {
|
|
|
+ runId: initial.data.run.runId, retryOutputIds: ['O1'], confirmed: true,
|
|
|
+ sessionId: `session-concurrent-retry-${Date.now()}`, promptId: 'prompt-concurrent-retry'
|
|
|
+ };
|
|
|
+ const firstPromise = runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ await started;
|
|
|
+ const concurrent = await runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const statusDuring = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
|
|
|
+ releaseGeneration();
|
|
|
+ const first = await firstPromise;
|
|
|
+ const repeated = await runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(concurrent.status, 'blocked');
|
|
|
+ assert.equal(statusDuring.status, 'blocked');
|
|
|
+ assert.equal(statusDuring.data.run.items[0].status, 'submitting');
|
|
|
+ assert.equal(first.status, 'ok');
|
|
|
+ assert.equal(repeated.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.equal(first.data.run.retryRequests.length, 1);
|
|
|
+ assert.equal(first.data.run.items[0].status, 'completed');
|
|
|
+ } finally {
|
|
|
+ const firstFile = first.files[0] || initial.files[0];
|
|
|
+ if (firstFile) removeTree(path.dirname(firstFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('a first-output failure still lets later outputs use the original reference', async () => {
|
|
|
+ const source = path.join(workspaceRoot, `首图失败参考-${Date.now()}.png`);
|
|
|
+ fs.writeFileSync(source, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
|
+ const plan = buildImageSetPlan({ request: '首图失败继续测试', preset: 'compact' });
|
|
|
+ plan.items = plan.items.slice(0, 2);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const references = [];
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const originalGenerate = provider.generate.bind(provider);
|
|
|
+ provider.generate = async item => {
|
|
|
+ references.push({ outputId: item.outputId, values: [...(item.referenceImages || [])] });
|
|
|
+ if (item.outputId === shortened.items[0].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
|
|
|
+ return originalGenerate(item);
|
|
|
+ };
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, sessionId: `session-first-fail-${Date.now()}`,
|
|
|
+ promptId: 'prompt-first-fail', workspaceRoot, referenceImages: [source], allowClaudeAttachments: false
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'partial');
|
|
|
+ assert.equal(result.data.run.items[1].status, 'completed');
|
|
|
+ const second = references.find(entry => entry.outputId === shortened.items[1].outputId);
|
|
|
+ assert.ok(second.values.some(value => /^data:image\//.test(value)));
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(source)) fs.unlinkSync(source);
|
|
|
+ if (result.files[0]) removeTree(path.dirname(result.files[0]));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('a public cost-free plan still verifies and its run envelope exposes no cost fields', async () => {
|
|
|
+ const internalPlan = buildOperationPlan({ prompt: '公开计划测试' });
|
|
|
+ const publicPlan = toPublicPlan(internalPlan);
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: publicPlan, confirmed: true, sessionId: `session-public-plan-${Date.now()}`,
|
|
|
+ promptId: 'prompt-public-plan', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok', JSON.stringify(result));
|
|
|
+ const serialized = JSON.stringify(result);
|
|
|
+ assert.doesNotMatch(serialized, /estimatedCostCny|revisionEstimatedCostCny|budgetLimitCny|costUnit|costBasis|actualCostCny|totalCostCny|¥/);
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('failed Gemini quality gate preserves file and rejects anchor', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'quality test' });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { identity: false }, violations: ['identity drift'], recommendations: [] } }) };
|
|
|
+ const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-quality-${Date.now()}`, promptId: 'prompt-quality', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'quality_rejected');
|
|
|
+ assert.equal(result.data.run.items[0].status, 'quality_rejected');
|
|
|
+ assert.equal(result.files.length, 1);
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('editing a rejected suite item creates an in-place revision and completes the original run', async () => {
|
|
|
+ const source = path.join(workspaceRoot, `返修商品原图-${Date.now()}.png`);
|
|
|
+ fs.writeFileSync(source, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修归位测试', preset: 'detail-page', productName: '返修归位商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['text defect'], recommendations: [] } }) };
|
|
|
+ const initial = await runGenerate({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-source-${Date.now()}`, promptId: 'prompt-revision-source',
|
|
|
+ workspaceRoot, referenceImages: [source], allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const revisionProvider = new FakeImageProvider();
|
|
|
+ const editInput = {
|
|
|
+ image: originalFile, prompt: '修复文字和构图缺陷', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-edit-${Date.now()}`, promptId: 'prompt-revision-edit',
|
|
|
+ workspaceRoot, qualityReview: true
|
|
|
+ };
|
|
|
+ const revised = await runEdit(editInput, { provider: revisionProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const repeated = await runEdit(editInput, { provider: revisionProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(initial.status, 'quality_rejected');
|
|
|
+ assert.equal(revised.status, 'ok');
|
|
|
+ assert.equal(revised.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(revised.data.run.items[0].status, 'completed');
|
|
|
+ assert.equal(revised.data.run.items[0].revisions.length, 1);
|
|
|
+ assert.equal(revised.data.run.items[0].revisions[0].width, 3024);
|
|
|
+ assert.equal(revised.data.run.items[0].revisions[0].height, 1296);
|
|
|
+ assert.match(path.basename(revised.files[0]), new RegExp(`^${shortened.items[0].outputId}-v2-1\\.png$`));
|
|
|
+ assert.equal(path.dirname(revised.files[0]), path.dirname(originalFile));
|
|
|
+ assert.equal(fs.existsSync(originalFile), true);
|
|
|
+ assert.equal(fs.existsSync(source), false);
|
|
|
+ assert.equal(revised.data.run.sourceOrganization.status, 'completed');
|
|
|
+ assert.equal(revisionProvider.generations, 1);
|
|
|
+ assert.equal(repeated.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(revisionProvider.generations, 1);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(source)) fs.unlinkSync(source);
|
|
|
+ if (originalFile) removeTree(path.dirname(originalFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('editing an already-passed suite image keeps the legacy standalone edit behavior', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '已通过套图独立编辑测试', preset: 'compact', productName: '已通过编辑商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-passed-edit-source-${Date.now()}`, promptId: 'prompt-passed-edit-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const edited = await runEdit({
|
|
|
+ image: originalFile, prompt: '独立调整背景', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-passed-edit-${Date.now()}`, promptId: 'prompt-passed-edit', workspaceRoot
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(initial.status, 'ok');
|
|
|
+ assert.equal(edited.status, 'ok');
|
|
|
+ assert.notEqual(edited.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.notEqual(path.dirname(edited.files[0]), path.dirname(originalFile));
|
|
|
+ assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
|
|
|
+ } finally {
|
|
|
+ if (originalFile) removeTree(path.dirname(originalFile));
|
|
|
+ if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('a hard-linked passed suite image still uses legacy standalone edit behavior', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '已通过套图硬链接兼容测试', preset: 'compact', productName: '硬链接兼容商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-passed-hardlink-source-${Date.now()}`, promptId: 'prompt-passed-hardlink-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const alias = path.join(path.dirname(originalFile), 'passed-hardlink-alias.png');
|
|
|
+ fs.linkSync(originalFile, alias);
|
|
|
+ const edited = await runEdit({
|
|
|
+ image: originalFile, prompt: '独立调整已通过图片', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-passed-hardlink-edit-${Date.now()}`, promptId: 'prompt-passed-hardlink-edit', workspaceRoot
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(edited.status, 'ok');
|
|
|
+ assert.notEqual(edited.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
|
|
|
+ } finally {
|
|
|
+ if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
|
|
|
+ if (originalFile) removeTree(path.dirname(originalFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('a hard-linked rejected suite source is blocked before an in-place paid revision', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修硬链接阻断测试', preset: 'compact', productName: '返修硬链接商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-rejected-hardlink-source-${Date.now()}`, promptId: 'prompt-rejected-hardlink-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ fs.linkSync(originalFile, path.join(path.dirname(originalFile), 'rejected-hardlink-alias.png'));
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const edited = await runEdit({
|
|
|
+ image: originalFile, prompt: '尝试返修硬链接源图', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-rejected-hardlink-edit-${Date.now()}`, promptId: 'prompt-rejected-hardlink-edit', workspaceRoot
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(edited.status, 'blocked');
|
|
|
+ assert.equal(provider.generations, 0);
|
|
|
+ assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('a hard-linked image that passed an earlier revision can be edited standalone', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '返修通过后硬链接兼容测试', preset: 'compact', productName: '返修后兼容商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revised-hardlink-source-${Date.now()}`, promptId: 'prompt-revised-hardlink-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const revised = await runEdit({
|
|
|
+ image: originalFile, prompt: '先完成原位返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revised-hardlink-revision-${Date.now()}`, promptId: 'prompt-revised-hardlink-revision', workspaceRoot
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ const revisedFile = revised.files[0];
|
|
|
+ fs.linkSync(revisedFile, path.join(path.dirname(revisedFile), 'revised-passed-hardlink-alias.png'));
|
|
|
+ const edited = await runEdit({
|
|
|
+ image: revisedFile, prompt: '返修通过后做普通独立编辑', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revised-hardlink-edit-${Date.now()}`, promptId: 'prompt-revised-hardlink-edit', workspaceRoot
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(revised.status, 'ok');
|
|
|
+ assert.equal(edited.status, 'ok');
|
|
|
+ assert.notEqual(edited.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.notEqual(path.dirname(edited.files[0]), path.dirname(revisedFile));
|
|
|
+ assert.equal(readState(path.dirname(revisedFile)).items[0].revisions.length, 1);
|
|
|
+ } finally {
|
|
|
+ if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
|
|
|
+ if (originalFile) removeTree(path.dirname(originalFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('a rejected in-place revision stays in the suite and preserves revision history', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修再次拒绝测试', preset: 'compact', productName: '返修拒绝商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: { defects: false }, violations: ['still defective'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-reject-source-${Date.now()}`, promptId: 'prompt-revision-reject-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const revised = await runEdit({
|
|
|
+ image: originalFile, prompt: '再次修复', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-reject-edit-${Date.now()}`, promptId: 'prompt-revision-reject-edit', workspaceRoot
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(revised.status, 'quality_rejected');
|
|
|
+ assert.equal(revised.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(revised.data.run.items[0].status, 'quality_rejected');
|
|
|
+ assert.equal(revised.data.run.items[0].revisions[0].status, 'quality_rejected');
|
|
|
+ assert.match(path.basename(revised.files[0]), new RegExp(`^${shortened.items[0].outputId}-v2-1\\.png$`));
|
|
|
+ assert.equal(fs.existsSync(originalFile), true);
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('ambiguous in-place revision submission is not generated twice', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修提交不确定测试', preset: 'compact', productName: '返修不确定商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-unknown-source-${Date.now()}`, promptId: 'prompt-revision-unknown-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const provider = new FakeImageProvider({ unknownAt: 1 });
|
|
|
+ const editInput = {
|
|
|
+ image: originalFile, prompt: '不确定提交返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-unknown-edit-${Date.now()}`, promptId: 'prompt-revision-unknown-edit', workspaceRoot
|
|
|
+ };
|
|
|
+ const first = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const second = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(first.status, 'submission_unknown');
|
|
|
+ assert.equal(first.data.run.items[0].revisions[0].status, 'submission_unknown');
|
|
|
+ assert.equal(second.status, 'submission_unknown');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('concurrent in-place revisions use an exclusive per-item lock', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修并发锁测试', preset: 'compact', productName: '返修并发商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-concurrent-source-${Date.now()}`, promptId: 'prompt-revision-concurrent-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ let releaseGeneration;
|
|
|
+ let markEntered;
|
|
|
+ const entered = new Promise(resolve => { markEntered = resolve; });
|
|
|
+ const gate = new Promise(resolve => { releaseGeneration = resolve; });
|
|
|
+ const provider = {
|
|
|
+ generations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ markEntered();
|
|
|
+ await gate;
|
|
|
+ return { requestId: 'revision-concurrent-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const editInput = {
|
|
|
+ image: originalFile, prompt: '并发返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-concurrent-edit-${Date.now()}`, promptId: 'prompt-revision-concurrent-edit', workspaceRoot
|
|
|
+ };
|
|
|
+ const firstPromise = runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ await entered;
|
|
|
+ const activeStatus = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
|
|
|
+ const concurrent = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ releaseGeneration();
|
|
|
+ const first = await firstPromise;
|
|
|
+ try {
|
|
|
+ assert.equal(activeStatus.status, 'blocked');
|
|
|
+ assert.equal(activeStatus.data.run.items[0].status, 'revising');
|
|
|
+ assert.equal(concurrent.status, 'blocked');
|
|
|
+ assert.equal(first.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.equal(fs.existsSync(path.join(path.dirname(originalFile), `.revision-${shortened.items[0].outputId}.lock`)), false);
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('run-level lock prevents a retry on another item from overwriting an active edit', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '跨项编辑续跑并发', preset: 'compact', productName: '跨项并发商品' });
|
|
|
+ plan.items = plan.items.slice(0, 2);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, sessionId: `session-cross-item-source-${Date.now()}`,
|
|
|
+ promptId: 'prompt-cross-item-source', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ let releaseEdit;
|
|
|
+ let signalStarted;
|
|
|
+ const editStarted = new Promise(resolve => { signalStarted = resolve; });
|
|
|
+ const editGate = new Promise(resolve => { releaseEdit = resolve; });
|
|
|
+ const editProvider = new FakeImageProvider();
|
|
|
+ editProvider.generate = async item => {
|
|
|
+ editProvider.generations += 1;
|
|
|
+ signalStarted();
|
|
|
+ await editGate;
|
|
|
+ return { requestId: 'cross-item-edit-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ };
|
|
|
+ const editPromise = runEdit({
|
|
|
+ image: initial.data.run.items[0].files[0], prompt: '修复第一项', confirmed: true,
|
|
|
+ sessionId: `session-cross-item-edit-${Date.now()}`, promptId: 'prompt-cross-item-edit'
|
|
|
+ }, { provider: editProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ await editStarted;
|
|
|
+ const retryProvider = new FakeImageProvider();
|
|
|
+ const blockedRetry = await runGenerate({
|
|
|
+ runId: initial.data.run.runId, retryOutputIds: [initial.data.run.items[1].outputId], confirmed: true,
|
|
|
+ sessionId: `session-cross-item-retry-${Date.now()}`, promptId: 'prompt-cross-item-retry'
|
|
|
+ }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ releaseEdit();
|
|
|
+ const edited = await editPromise;
|
|
|
+ try {
|
|
|
+ assert.equal(blockedRetry.status, 'blocked');
|
|
|
+ assert.equal(retryProvider.generations, 0);
|
|
|
+ assert.equal(editProvider.generations, 1);
|
|
|
+ assert.equal(edited.data.run.items[0].status, 'completed');
|
|
|
+ assert.equal(edited.data.run.items[1].status, 'quality_rejected');
|
|
|
+ assert.equal(edited.data.run.items[1].requestId, initial.data.run.items[1].requestId);
|
|
|
+ } finally {
|
|
|
+ const firstFile = edited.files[0] || initial.files[0];
|
|
|
+ if (firstFile) removeTree(path.dirname(firstFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('status never races an active in-place revision download', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '返修下载并发状态测试', preset: 'compact', productName: '返修下载并发商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-download-source-${Date.now()}`, promptId: 'prompt-revision-download-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ let releaseDownload;
|
|
|
+ let markDownloading;
|
|
|
+ const downloading = new Promise(resolve => { markDownloading = resolve; });
|
|
|
+ const gate = new Promise(resolve => { releaseDownload = resolve; });
|
|
|
+ const provider = {
|
|
|
+ generations: 0,
|
|
|
+ materializations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ return { requestId: 'revision-download-concurrent-1', model: item.providerModel, images: ['https://result.test/revision-concurrent.png'], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ this.materializations += 1;
|
|
|
+ markDownloading();
|
|
|
+ await gate;
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const revisionPromise = runEdit({
|
|
|
+ image: originalFile, prompt: '并发下载返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-download-edit-${Date.now()}`, promptId: 'prompt-revision-download-edit', workspaceRoot
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ await downloading;
|
|
|
+ const activeStatus = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
|
|
|
+ releaseDownload();
|
|
|
+ const revised = await revisionPromise;
|
|
|
+ try {
|
|
|
+ assert.equal(activeStatus.status, 'blocked');
|
|
|
+ assert.equal(activeStatus.data.run.items[0].status, 'revising');
|
|
|
+ assert.equal(revised.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.equal(provider.materializations, 1);
|
|
|
+ assert.equal(readState(path.dirname(originalFile)).items[0].status, 'completed');
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('status blocks a live external revision-lock owner and recovers its stale lock after exit', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '跨进程返修锁状态测试', preset: 'compact', productName: '跨进程返修锁商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-external-lock-source-${Date.now()}`, promptId: 'prompt-external-lock-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const lockPath = path.join(path.dirname(originalFile), `.revision-${shortened.items[0].outputId}.lock`);
|
|
|
+ const owner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' });
|
|
|
+ await new Promise((resolve, reject) => {
|
|
|
+ owner.once('spawn', resolve);
|
|
|
+ owner.once('error', reject);
|
|
|
+ });
|
|
|
+ fs.writeFileSync(lockPath, `${JSON.stringify({ pid: owner.pid, requestKey: 'external-owner', createdAt: new Date().toISOString() })}\n`, { flag: 'wx' });
|
|
|
+ try {
|
|
|
+ const whileAlive = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { workspaceRoot });
|
|
|
+ assert.equal(whileAlive.status, 'blocked');
|
|
|
+ assert.equal(fs.existsSync(lockPath), true);
|
|
|
+ const exited = new Promise(resolve => owner.once('exit', resolve));
|
|
|
+ owner.kill();
|
|
|
+ await exited;
|
|
|
+ const afterExit = await refreshRun({ runId: initial.data.run.runId, refresh: false }, { workspaceRoot });
|
|
|
+ assert.equal(afterExit.status, 'quality_rejected');
|
|
|
+ assert.equal(fs.existsSync(lockPath), false);
|
|
|
+ assert.equal(afterExit.data.run.items[0].status, 'quality_rejected');
|
|
|
+ } finally {
|
|
|
+ if (!owner.killed) owner.kill();
|
|
|
+ if (originalFile) removeTree(path.dirname(originalFile));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('in-place revision resumes a persisted remote result without another generation', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修远程恢复测试', preset: 'compact', productName: '返修恢复商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-remote-source-${Date.now()}`, promptId: 'prompt-revision-remote-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ let downloadAttempts = 0;
|
|
|
+ const provider = {
|
|
|
+ generations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ return { requestId: 'revision-remote-1', model: item.providerModel, images: ['https://result.test/revision.png'], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ downloadAttempts += 1;
|
|
|
+ if (downloadAttempts === 1) throw new ProviderError('provider_unavailable', 'revision download interrupted');
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const first = await runEdit({
|
|
|
+ image: originalFile, prompt: '恢复远程返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-remote-edit-${Date.now()}`, promptId: 'prompt-revision-remote-edit', workspaceRoot
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const differentRequest = await runEdit({
|
|
|
+ image: originalFile, prompt: '换一个请求尝试再次返修', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-remote-edit-other-${Date.now()}`, promptId: 'prompt-revision-remote-edit-other', workspaceRoot
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ const resumed = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(first.data.run.items[0].status, 'revising');
|
|
|
+ assert.equal(differentRequest.status, 'blocked');
|
|
|
+ assert.equal(differentRequest.data.run.runId, initial.data.run.runId);
|
|
|
+ assert.equal(resumed.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.equal(resumed.data.run.items[0].revisions[0].status, 'completed');
|
|
|
+ assert.equal(resumed.data.run.items[0].quality.status, 'unverified');
|
|
|
+ assert.equal(path.dirname(resumed.files[0]), path.dirname(originalFile));
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('status converts an interrupted in-place revision submit into submission_unknown', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '套图返修中断测试', preset: 'compact', productName: '返修中断商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-interrupt-source-${Date.now()}`, promptId: 'prompt-revision-interrupt-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const runDirectory = path.dirname(originalFile);
|
|
|
+ const state = readState(runDirectory);
|
|
|
+ state.items[0].status = 'revising';
|
|
|
+ state.items[0].revisions = [{
|
|
|
+ revisionId: 'revision_interrupted', requestKey: 'interrupted-key', version: 2,
|
|
|
+ status: 'submitting', sourceFiles: [originalFile], files: [], createdAt: new Date().toISOString()
|
|
|
+ }];
|
|
|
+ state.status = 'running';
|
|
|
+ writeState(runDirectory, state);
|
|
|
+ const recovered = await refreshRun({ runId: state.runId, refresh: false }, { workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(recovered.status, 'submission_unknown');
|
|
|
+ assert.equal(recovered.data.run.items[0].status, 'submission_unknown');
|
|
|
+ assert.equal(recovered.data.run.items[0].revisions[0].status, 'submission_unknown');
|
|
|
+ } finally { if (originalFile) removeTree(runDirectory); }
|
|
|
+});
|
|
|
+
|
|
|
+test('in-place domestic revision inherits the original required platform validation', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '淘宝主图返修规则继承', preset: 'main-image', platform: '淘宝', productName: '返修规则商品' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-revision-domestic-source-${Date.now()}`, promptId: 'prompt-revision-domestic-source',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new DomesticImageProvider(1024, 1024), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const originalFile = initial.files[0];
|
|
|
+ const revised = await runEdit({
|
|
|
+ image: originalFile, prompt: '返修淘宝白底主图', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-revision-domestic-edit-${Date.now()}`, promptId: 'prompt-revision-domestic-edit', workspaceRoot
|
|
|
+ }, { provider: new DomesticImageProvider(1024, 768), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(revised.status, 'quality_rejected');
|
|
|
+ assert.equal(revised.data.run.items[0].revisions[0].platformValidation.status, 'rejected');
|
|
|
+ assert.equal(revised.data.run.items[0].platformRule.platformKey, 'taobao');
|
|
|
+ assert.equal(path.dirname(revised.files[0]), path.dirname(originalFile));
|
|
|
+ } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('domestic generation validates real dimensions without changing legacy execution', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '淘宝商品主图', preset: 'main-image', platform: '淘宝' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const provider = new DomesticImageProvider(1024, 1024);
|
|
|
+ let reviewContext;
|
|
|
+ const reviewer = { review: async (_originals, _generated, input) => {
|
|
|
+ reviewContext = input;
|
|
|
+ return { model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } };
|
|
|
+ } };
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-domestic-pass-${Date.now()}`, promptId: 'prompt-domestic-pass',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider, visionProvider: reviewer, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(result.data.run.items[0].platformValidation.status, 'warning');
|
|
|
+ assert.equal(result.data.run.items[0].platformValidation.files[0].metadata.width, 1024);
|
|
|
+ assert.equal(reviewContext.platformRule.platformKey, 'taobao');
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('required domestic ratio rejects a paid result but preserves its file', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '淘宝商品主图比例拒绝', preset: 'main-image', platform: '淘宝' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const provider = new DomesticImageProvider(1024, 768);
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2,
|
|
|
+ sessionId: `session-domestic-reject-${Date.now()}`, promptId: 'prompt-domestic-reject',
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'quality_rejected');
|
|
|
+ assert.equal(result.data.run.items[0].status, 'quality_rejected');
|
|
|
+ assert.equal(result.data.run.items[0].platformValidation.status, 'rejected');
|
|
|
+ assert.equal(result.files.length, 1);
|
|
|
+ } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('idempotent recovery cannot bypass missing domestic required validation', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '淘宝主图中断恢复', preset: 'main-image', platform: '淘宝' });
|
|
|
+ plan.items = plan.items.slice(0, 1);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ const sessionId = `session-domestic-recovery-${Date.now()}`;
|
|
|
+ const promptId = 'prompt-domestic-recovery';
|
|
|
+ const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: shortened.planHash });
|
|
|
+ const runId = createReadableRunId({ productName: shortened.productName, requestKey });
|
|
|
+ const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
|
|
|
+ fs.mkdirSync(runDirectory, { recursive: true });
|
|
|
+ const generated = path.join(runDirectory, 'A1-1.png');
|
|
|
+ fs.writeFileSync(generated, fakePng(1024, 768));
|
|
|
+ writeState(runDirectory, {
|
|
|
+ schemaVersion: 3, runId, requestKey, planId: shortened.planId, planHash: shortened.planHash,
|
|
|
+ status: 'completed', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
|
+ workspaceRoot, productName: shortened.productName, estimatedCostCny: shortened.estimatedCostCny,
|
|
|
+ costUnit: 'CNY', budgetLimitCny: 2, sourceImages: [],
|
|
|
+ items: [{ ...shortened.items[0], status: 'completed', requestId: 'existing-paid-request', files: [generated] }]
|
|
|
+ });
|
|
|
+ const provider = new DomesticImageProvider(2048, 2048);
|
|
|
+ const result = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, budgetLimitCny: 2, sessionId, promptId,
|
|
|
+ workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(provider.generations, 0);
|
|
|
+ assert.equal(result.status, 'quality_rejected');
|
|
|
+ assert.equal(result.data.run.items[0].platformValidation.status, 'rejected');
|
|
|
+ assert.equal(fs.existsSync(generated), true);
|
|
|
+ } finally { removeTree(runDirectory); }
|
|
|
+});
|
|
|
+
|
|
|
+test('interrupted submitting state becomes submission_unknown and is never regenerated', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: `interrupted-${Date.now()}` });
|
|
|
+ const sessionId = `session-interrupted-${Date.now()}`;
|
|
|
+ const promptId = 'prompt-interrupted';
|
|
|
+ const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: plan.planHash });
|
|
|
+ const runId = `run_${requestKey.slice(0, 20)}`;
|
|
|
+ const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'legacy' });
|
|
|
+ writeState(runDirectory, {
|
|
|
+ schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
|
|
|
+ status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
|
+ workspaceRoot, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
|
|
|
+ items: [{ ...plan.items[0], status: 'submitting', requestId: '', files: [] }]
|
|
|
+ });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId, promptId, workspaceRoot, allowClaudeAttachments: false }, { provider, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'submission_unknown');
|
|
|
+ assert.equal(provider.generations, 0);
|
|
|
+ } finally { removeTree(runDirectory); }
|
|
|
+});
|
|
|
+
|
|
|
+test('completed run uses a Chinese product directory, moves root images, and copies nested images', async () => {
|
|
|
+ const caseRoot = fs.mkdtempSync(path.join(workspaceRoot, 'source-organize-'));
|
|
|
+ const rootImage = path.join(caseRoot, '..', `根目录商品-${Date.now()}.png`);
|
|
|
+ const nestedDirectory = path.join(caseRoot, '参考图');
|
|
|
+ const nestedImage = path.join(nestedDirectory, '商品背面.jpg');
|
|
|
+ fs.mkdirSync(nestedDirectory, { recursive: true });
|
|
|
+ fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 1]));
|
|
|
+ fs.writeFileSync(nestedImage, Buffer.from([255, 216, 255, 224, 2]));
|
|
|
+ const plan = buildOperationPlan({ prompt: '整理商品原图', productName: 'MORI 咖啡/经典版' });
|
|
|
+ const input = {
|
|
|
+ plan, confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-organize-${Date.now()}`, promptId: 'prompt-organize',
|
|
|
+ referenceImages: [rootImage, nestedImage], allowClaudeAttachments: false
|
|
|
+ };
|
|
|
+ const result = await runGenerate(input, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.match(result.data.run.runId, /^MORI-咖啡-经典版-[a-f0-9]{8}$/);
|
|
|
+ assert.ok(result.files[0].includes(`${path.sep}输出${path.sep}商品套图${path.sep}`));
|
|
|
+ assert.equal(fs.existsSync(rootImage), false);
|
|
|
+ assert.equal(fs.existsSync(nestedImage), true);
|
|
|
+ assert.equal(result.data.run.sourceOrganization.moved, 1);
|
|
|
+ assert.equal(result.data.run.sourceOrganization.copied, 1);
|
|
|
+ assert.ok(result.data.run.sourceOrganization.files.every(file => fs.existsSync(file.path)));
|
|
|
+ assert.match(result.assistantMessage, /已移动/);
|
|
|
+ assert.match(result.assistantMessage, /已复制/);
|
|
|
+ } finally {
|
|
|
+ if (result.files[0]) removeTree(path.dirname(result.files[0]));
|
|
|
+ if (fs.existsSync(path.resolve(rootImage))) fs.unlinkSync(path.resolve(rootImage));
|
|
|
+ removeTree(caseRoot);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('non-completed run never moves a project-root source image', async () => {
|
|
|
+ const rootImage = path.join(workspaceRoot, `失败保留-${Date.now()}.png`);
|
|
|
+ fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 3]));
|
|
|
+ const plan = buildOperationPlan({ prompt: '失败时保留原图', productName: '失败测试商品' });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ provider.generate = async () => { provider.generations += 1; throw new ProviderError('submission_unknown', 'ambiguous'); };
|
|
|
+ const result = await executePlan({
|
|
|
+ plan, confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-source-failure-${Date.now()}`, promptId: 'prompt-source-failure',
|
|
|
+ referenceImages: [rootImage], allowClaudeAttachments: false, organizeSources: true
|
|
|
+ }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'submission_unknown');
|
|
|
+ assert.equal(fs.existsSync(rootImage), true);
|
|
|
+ assert.equal(result.data.run.sourceImages[0].status, 'pending');
|
|
|
+ assert.equal(result.data.run.sourceOrganization, undefined);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
|
|
|
+ const currentRun = resolveRunDirectory({ workspaceRoot, packageRoot, runId: result.data.run.runId });
|
|
|
+ removeTree(currentRun.runDirectory);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('source mutation blocks organization without changing the successful paid result', async () => {
|
|
|
+ const rootImage = path.join(workspaceRoot, `变化原图-${Date.now()}.png`);
|
|
|
+ fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 4]));
|
|
|
+ const plan = buildOperationPlan({ prompt: '原图变化保护', productName: '变化保护商品' });
|
|
|
+ const mutatingVision = { review: async () => {
|
|
|
+ fs.appendFileSync(rootImage, Buffer.from([5]));
|
|
|
+ return { model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } };
|
|
|
+ } };
|
|
|
+ const result = await executePlan({
|
|
|
+ plan, confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-source-change-${Date.now()}`, promptId: 'prompt-source-change',
|
|
|
+ referenceImages: [rootImage], allowClaudeAttachments: false, organizeSources: true
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: mutatingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(fs.existsSync(rootImage), true);
|
|
|
+ assert.equal(result.data.run.sourceOrganization.status, 'failed');
|
|
|
+ assert.match(result.assistantMessage, /整理失败/);
|
|
|
+ assert.ok(result.warnings.some(warning => /生成期间发生变化/.test(warning)));
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
|
|
|
+ if (result.files[0]) removeTree(path.dirname(result.files[0]));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('idempotent retry completes a prepared root-image move without regenerating', async () => {
|
|
|
+ const rootImage = path.join(workspaceRoot, `待完成移动-${Date.now()}.png`);
|
|
|
+ const bytes = Buffer.from([137, 80, 78, 71, 6]);
|
|
|
+ fs.writeFileSync(rootImage, bytes);
|
|
|
+ const plan = buildOperationPlan({ prompt: '恢复原图移动', productName: '恢复移动商品' });
|
|
|
+ const sessionId = `session-source-recover-${Date.now()}`;
|
|
|
+ const promptId = 'prompt-source-recover';
|
|
|
+ const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: plan.planHash });
|
|
|
+ const runId = createReadableRunId({ productName: plan.productName, requestKey });
|
|
|
+ const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
|
|
|
+ const sourceDirectory = path.join(runDirectory, '商品原图');
|
|
|
+ const destination = path.join(sourceDirectory, `01-${path.basename(rootImage)}`);
|
|
|
+ const generated = path.join(runDirectory, 'O1-1.png');
|
|
|
+ fs.mkdirSync(sourceDirectory, { recursive: true });
|
|
|
+ fs.copyFileSync(rootImage, destination);
|
|
|
+ fs.writeFileSync(generated, 'fake');
|
|
|
+ const contentHash = createHash('sha256').update(bytes).digest('hex');
|
|
|
+ writeState(runDirectory, {
|
|
|
+ schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
|
|
|
+ status: 'completed', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), workspaceRoot,
|
|
|
+ productName: plan.productName, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
|
|
|
+ sourceImages: [{ index: 1, originalRelativePath: path.basename(rootImage), originalName: path.basename(rootImage), contentHash, size: bytes.length, action: 'move', status: 'prepared', destination }],
|
|
|
+ sourceOrganization: { status: 'prepared', directory: sourceDirectory, moved: 0, copied: 0, files: [{ originalPath: rootImage, path: destination, action: 'move', status: 'prepared' }] },
|
|
|
+ items: [{ ...plan.items[0], status: 'completed', requestId: 'existing', files: [generated] }]
|
|
|
+ });
|
|
|
+ const provider = new FakeImageProvider();
|
|
|
+ const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId, promptId, allowClaudeAttachments: false }, { provider, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 0);
|
|
|
+ assert.equal(fs.existsSync(rootImage), false);
|
|
|
+ assert.equal(fs.existsSync(destination), true);
|
|
|
+ assert.equal(result.data.run.sourceOrganization.status, 'completed');
|
|
|
+ assert.equal(result.data.run.sourceOrganization.moved, 1);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
|
|
|
+ removeTree(runDirectory);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('status recovers source organization after the last item completed before finalization', async () => {
|
|
|
+ const rootImage = path.join(workspaceRoot, `状态恢复移动-${Date.now()}.png`);
|
|
|
+ const bytes = Buffer.from([137, 80, 78, 71, 8]);
|
|
|
+ fs.writeFileSync(rootImage, bytes);
|
|
|
+ const plan = buildOperationPlan({ prompt: '状态恢复原图整理', productName: '状态恢复商品' });
|
|
|
+ const requestKey = createRequestKey({ sessionId: `session-status-source-${Date.now()}`, promptId: 'prompt-status-source', action: 'generate', planHash: plan.planHash });
|
|
|
+ const runId = createReadableRunId({ productName: plan.productName, requestKey });
|
|
|
+ const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
|
|
|
+ const generated = path.join(runDirectory, 'O1-1.png');
|
|
|
+ fs.mkdirSync(runDirectory, { recursive: true });
|
|
|
+ fs.writeFileSync(generated, 'fake');
|
|
|
+ const contentHash = createHash('sha256').update(bytes).digest('hex');
|
|
|
+ writeState(runDirectory, {
|
|
|
+ schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
|
|
|
+ status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), workspaceRoot,
|
|
|
+ productName: plan.productName, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
|
|
|
+ sourceImages: [{ index: 1, originalRelativePath: path.basename(rootImage), originalName: path.basename(rootImage), contentHash, size: bytes.length, action: 'move', status: 'pending' }],
|
|
|
+ items: [{ ...plan.items[0], status: 'completed', requestId: 'existing', files: [generated] }]
|
|
|
+ });
|
|
|
+ const result = await refreshRun({ runId, refresh: false }, { workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(result.data.run.status, 'completed');
|
|
|
+ assert.equal(result.data.run.sourceOrganization.status, 'completed');
|
|
|
+ assert.equal(fs.existsSync(rootImage), false);
|
|
|
+ assert.equal(fs.existsSync(result.data.run.sourceOrganization.files[0].path), true);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
|
|
|
+ removeTree(runDirectory);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('edit operations never organize or remove a project-root source image', async () => {
|
|
|
+ const rootImage = path.join(workspaceRoot, `编辑源图-${Date.now()}.png`);
|
|
|
+ fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 7]));
|
|
|
+ const result = await runEdit({
|
|
|
+ image: rootImage, prompt: '保持商品不变,仅调整背景', confirmed: true, budgetLimitCny: 1,
|
|
|
+ sessionId: `session-edit-source-${Date.now()}`, promptId: 'prompt-edit-source', allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(result.status, 'ok');
|
|
|
+ assert.equal(fs.existsSync(rootImage), true);
|
|
|
+ assert.equal(result.data.run.sourceImages.length, 0);
|
|
|
+ assert.equal(result.data.run.sourceOrganization.status, 'not_applicable');
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
|
|
|
+ if (result.files[0]) removeTree(path.dirname(result.files[0]));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('remote result URL survives download failure and resumes without another generation', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'remote recovery' });
|
|
|
+ let downloadAttempts = 0;
|
|
|
+ const provider = {
|
|
|
+ generations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ return { requestId: 'remote-1', model: item.providerModel, images: ['https://result.test/image.png'], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ downloadAttempts += 1;
|
|
|
+ if (downloadAttempts <= 3) throw new ProviderError('provider_unavailable', 'download interrupted');
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'fake', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const input = { plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-remote-${Date.now()}`, promptId: 'prompt-remote', workspaceRoot, allowClaudeAttachments: false };
|
|
|
+ const first = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ const runId = first.data.run.runId;
|
|
|
+ const resumed = await refreshRun({ runId, refresh: true }, { provider, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(first.data.run.items[0].status, 'receiving');
|
|
|
+ assert.equal(resumed.status, 'ok');
|
|
|
+ assert.equal(provider.generations, 1);
|
|
|
+ assert.equal(downloadAttempts, 4);
|
|
|
+ assert.equal(resumed.files.length, 1);
|
|
|
+ assert.equal(resumed.data.run.items[0].quality.status, 'unverified');
|
|
|
+ } finally { if (resumed.files[0]) removeTree(path.dirname(resumed.files[0])); }
|
|
|
+});
|
|
|
+
|
|
|
+test('same-run retry with an old file resumes remote download into its versioned target', async () => {
|
|
|
+ const plan = buildOperationPlan({ prompt: 'versioned remote recovery', productName: '版本化恢复商品' });
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan, confirmed: true, sessionId: `session-versioned-source-${Date.now()}`,
|
|
|
+ promptId: 'prompt-versioned-source', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
|
|
|
+ const oldFile = initial.files[0];
|
|
|
+ let downloadAttempts = 0;
|
|
|
+ const retryProvider = {
|
|
|
+ generations: 0,
|
|
|
+ async generate(item) {
|
|
|
+ this.generations += 1;
|
|
|
+ return { requestId: 'remote-versioned-1', model: item.providerModel, images: ['https://result.test/versioned.png'], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ downloadAttempts += 1;
|
|
|
+ if (downloadAttempts <= 3) throw new ProviderError('provider_unavailable', 'download interrupted');
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'retry-file', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const retried = await runGenerate({
|
|
|
+ runId: initial.data.run.runId, retryOutputIds: ['O1'], confirmed: true,
|
|
|
+ sessionId: `session-versioned-retry-${Date.now()}`, promptId: 'prompt-versioned-retry'
|
|
|
+ }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ const resumed = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider: retryProvider, workspaceRoot });
|
|
|
+ try {
|
|
|
+ assert.equal(retried.data.run.items[0].status, 'receiving');
|
|
|
+ assert.equal(retried.data.run.items[0].materializeOutputId, 'O1-retry1');
|
|
|
+ assert.equal(resumed.status, 'ok');
|
|
|
+ assert.equal(retryProvider.generations, 1);
|
|
|
+ assert.equal(downloadAttempts, 4);
|
|
|
+ assert.equal(path.basename(resumed.files[0]), 'O1-retry1-1.png');
|
|
|
+ assert.equal(fs.existsSync(oldFile), true);
|
|
|
+ } finally { if (oldFile) removeTree(path.dirname(oldFile)); }
|
|
|
+});
|
|
|
+
|
|
|
+test('status holds the run lock while recovering a download and blocks a retry on another item', async () => {
|
|
|
+ const plan = buildImageSetPlan({ request: '状态先持锁再续跑', preset: 'compact', productName: '状态恢复并发商品' });
|
|
|
+ plan.items = plan.items.slice(0, 2);
|
|
|
+ const shortened = rehashPlan(plan);
|
|
|
+ let initialDownloads = 0;
|
|
|
+ const initialProvider = {
|
|
|
+ async generate(item) {
|
|
|
+ return item.outputId === shortened.items[0].outputId
|
|
|
+ ? { requestId: 'status-first-remote', model: item.providerModel, images: ['https://result.test/status-first.png'], usage: {} }
|
|
|
+ : { requestId: 'status-second-inline', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
|
|
|
+ },
|
|
|
+ async materialize(value, runDirectory, outputId) {
|
|
|
+ if (/^https?:/.test(value)) {
|
|
|
+ initialDownloads += 1;
|
|
|
+ throw new ProviderError('provider_unavailable', 'download interrupted');
|
|
|
+ }
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'inline', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
|
|
|
+ const initial = await executePlan({
|
|
|
+ plan: shortened, confirmed: true, sessionId: `session-status-first-source-${Date.now()}`,
|
|
|
+ promptId: 'prompt-status-first-source', workspaceRoot, allowClaudeAttachments: false
|
|
|
+ }, { provider: initialProvider, visionProvider: rejectingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
|
|
|
+ let releaseDownload;
|
|
|
+ let signalDownload;
|
|
|
+ const downloadStarted = new Promise(resolve => { signalDownload = resolve; });
|
|
|
+ const downloadGate = new Promise(resolve => { releaseDownload = resolve; });
|
|
|
+ const recoveryProvider = {
|
|
|
+ async materialize(_value, runDirectory, outputId) {
|
|
|
+ signalDownload();
|
|
|
+ await downloadGate;
|
|
|
+ const file = path.join(runDirectory, `${outputId}-1.png`);
|
|
|
+ fs.writeFileSync(file, 'recovered', { flag: 'wx' });
|
|
|
+ return file;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const statusPromise = refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider: recoveryProvider, workspaceRoot });
|
|
|
+ await downloadStarted;
|
|
|
+ const retryProvider = new FakeImageProvider();
|
|
|
+ const blockedRetry = await runGenerate({
|
|
|
+ runId: initial.data.run.runId, retryOutputIds: [shortened.items[1].outputId], confirmed: true,
|
|
|
+ sessionId: `session-status-first-retry-${Date.now()}`, promptId: 'prompt-status-first-retry'
|
|
|
+ }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
|
|
|
+ releaseDownload();
|
|
|
+ const recovered = await statusPromise;
|
|
|
+ try {
|
|
|
+ assert.equal(initialDownloads, 3);
|
|
|
+ assert.equal(blockedRetry.status, 'blocked');
|
|
|
+ assert.equal(retryProvider.generations, 0);
|
|
|
+ assert.equal(recovered.data.run.items[0].status, 'completed');
|
|
|
+ assert.equal(recovered.data.run.items[1].status, 'quality_rejected');
|
|
|
+ } finally {
|
|
|
+ const firstFile = recovered.files[0] || initial.files[0];
|
|
|
+ if (firstFile) removeTree(path.dirname(firstFile));
|
|
|
+ }
|
|
|
+});
|