draftgo-cli 3.0.55 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -316
- package/package.json +5 -5
- package/resources/skill/SKILL.md +25 -24
- package/resources/skill/init/SKILL.md +5 -10
- package/resources/skill/manifest.json +2 -2
- package/resources/skill/references/aihub.md +10 -5
- package/resources/skill/references/chat-sdk.md +10 -0
- package/resources/skill/references/checkout.md +4 -4
- package/resources/skill/references/custom-services.md +65 -226
- package/resources/skill/references/data.md +3 -2
- package/resources/skill/references/frontend.md +96 -490
- package/resources/skill/references/mcp.md +39 -103
- package/resources/skill/references/runtime.md +3 -2
- package/resources/skill/story/SKILL.md +1 -2
- package/src/apiContractCache.js +112 -0
- package/src/cli.js +1 -21
- package/src/commandRegistry.js +6 -11
- package/src/commands/api.js +28 -8
- package/src/commands/check.js +1 -10
- package/src/commands/customService.js +2 -4
- package/src/commands/delete.js +23 -46
- package/src/commands/deploy.js +1 -1
- package/src/commands/help.js +16 -31
- package/src/commands/init.js +4 -10
- package/src/commands/listTargets.js +1 -1
- package/src/commands/local.js +2 -6
- package/src/commands/map.js +0 -11
- package/src/commands/status.js +1 -1
- package/src/commands/uninstall.js +3 -3
- package/src/commands/update.js +1 -1
- package/src/commands/verify.js +43 -21
- package/src/commands/{verifyUi.js → visualVerify.js} +28 -116
- package/src/commands/worklog.js +86 -0
- package/src/customServices.js +150 -33
- package/src/{localdev → localRuntime}/detect.js +1 -1
- package/src/{localdev → localRuntime}/mysqlClient.js +1 -1
- package/src/{localdev → localRuntime}/services.js +1 -1
- package/src/projectConfig.js +2 -0
- package/src/{installers/index.js → targets.js} +3 -5
- package/src/worklog.js +274 -0
- package/src/workspaceHealth.js +1 -1
- package/src/worktree/index.js +81 -51
- package/src/changelog.js +0 -276
- package/src/commands/changelog.js +0 -24
- package/src/commands/localDev.js +0 -9
- package/src/commands/sync.js +0 -46
- package/src/commands/task.js +0 -408
- package/src/commands/verifyUiCompat.js +0 -16
- /package/src/{localdev → localRuntime}/compose.js +0 -0
- /package/src/{localdev → localRuntime}/index.js +0 -0
package/src/worklog.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { dgDir } = require('./paths');
|
|
7
|
+
|
|
8
|
+
const STATUS = Object.freeze({ pending: '', active: '●', completed: '√' });
|
|
9
|
+
const LOCK_WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
|
|
11
|
+
function formatLocalDate(value = new Date()) {
|
|
12
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new TypeError('A valid Date is required.');
|
|
13
|
+
const year = String(value.getFullYear()).padStart(4, '0');
|
|
14
|
+
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
15
|
+
const day = String(value.getDate()).padStart(2, '0');
|
|
16
|
+
return `${year}-${month}-${day}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function worklogPath(projectDir) {
|
|
20
|
+
return path.join(dgDir(projectDir), 'worklog.md');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function invalid(detail) {
|
|
24
|
+
const error = new Error(`Invalid .draftgo/worklog.md: ${detail}`);
|
|
25
|
+
error.code = 'INVALID_WORKLOG';
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeText(value, name = 'Worklog text') {
|
|
30
|
+
const text = value == null ? '' : String(value).trim();
|
|
31
|
+
if (!text) throw new TypeError(`${name} must not be empty.`);
|
|
32
|
+
if (/[\r\n\u2028\u2029]/.test(text)) throw new TypeError(`${name} must be a single line.`);
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeDate(value, now = new Date()) {
|
|
37
|
+
const date = value == null || value === '' ? formatLocalDate(now) : String(value).trim();
|
|
38
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new TypeError('Worklog date must use YYYY-MM-DD.');
|
|
39
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
40
|
+
const parsed = new Date(year, month - 1, day);
|
|
41
|
+
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
|
|
42
|
+
throw new TypeError(`Invalid worklog date: ${date}.`);
|
|
43
|
+
}
|
|
44
|
+
return date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseStatus(marker) {
|
|
48
|
+
if (marker === '●') return 'active';
|
|
49
|
+
if (marker === '√') return 'completed';
|
|
50
|
+
if (marker === '') return 'pending';
|
|
51
|
+
throw invalid(`unsupported status marker ${JSON.stringify(marker)}.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseWorklog(source) {
|
|
55
|
+
if (typeof source !== 'string') throw new TypeError('Worklog source must be a string.');
|
|
56
|
+
if (!source) return [];
|
|
57
|
+
if (/\r(?!\n)/.test(source)) throw invalid('unsupported line ending.');
|
|
58
|
+
const normalized = source.replace(/\r\n/g, '\n');
|
|
59
|
+
const body = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
|
|
60
|
+
if (!body) return [];
|
|
61
|
+
const lines = body.split('\n');
|
|
62
|
+
const blocks = [];
|
|
63
|
+
let cursor = 0;
|
|
64
|
+
|
|
65
|
+
while (cursor < lines.length) {
|
|
66
|
+
const header = /^\[\s*(\d{4}-\d{2}-\d{2})\s*\]$/.exec(lines[cursor]);
|
|
67
|
+
if (!header) throw invalid(`expected a [ YYYY-MM-DD ] header at line ${cursor + 1}.`);
|
|
68
|
+
const date = normalizeDate(header[1]);
|
|
69
|
+
if (blocks.length && date <= blocks[blocks.length - 1].date) {
|
|
70
|
+
throw invalid(`date blocks must be strictly chronological (line ${cursor + 1}).`);
|
|
71
|
+
}
|
|
72
|
+
cursor += 1;
|
|
73
|
+
const entries = [];
|
|
74
|
+
while (cursor < lines.length && lines[cursor] !== '') {
|
|
75
|
+
const itemMatch = /^([1-9]\d*)\.\s+\[\s*(●|√)?\s*\]\s+(.+)$/.exec(lines[cursor]);
|
|
76
|
+
if (!itemMatch) throw invalid(`invalid item at line ${cursor + 1}.`);
|
|
77
|
+
const number = Number(itemMatch[1]);
|
|
78
|
+
if (!Number.isSafeInteger(number) || number !== entries.length + 1) {
|
|
79
|
+
throw invalid(`items must be consecutively numbered from 1 (line ${cursor + 1}).`);
|
|
80
|
+
}
|
|
81
|
+
const title = normalizeText(itemMatch[3], 'Worklog item');
|
|
82
|
+
const status = parseStatus(itemMatch[2] || '');
|
|
83
|
+
cursor += 1;
|
|
84
|
+
const notes = [];
|
|
85
|
+
while (cursor < lines.length && lines[cursor].startsWith('//')) {
|
|
86
|
+
const note = lines[cursor].slice(2).trim();
|
|
87
|
+
if (note) notes.push(note);
|
|
88
|
+
cursor += 1;
|
|
89
|
+
}
|
|
90
|
+
entries.push({ number, title, status, notes });
|
|
91
|
+
}
|
|
92
|
+
if (!entries.length) throw invalid(`date block ${date} has no items.`);
|
|
93
|
+
blocks.push({ date, entries });
|
|
94
|
+
if (cursor >= lines.length) break;
|
|
95
|
+
cursor += 1;
|
|
96
|
+
if (cursor >= lines.length || lines[cursor] === '') {
|
|
97
|
+
throw invalid(`date blocks must be separated by one blank line (line ${cursor + 1}).`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return blocks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderStatus(status) {
|
|
104
|
+
const marker = STATUS[status];
|
|
105
|
+
if (marker === undefined) throw new TypeError(`Unknown worklog status: ${status}.`);
|
|
106
|
+
return marker ? `[ ${marker} ]` : '[ ]';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderWorklog(blocks) {
|
|
110
|
+
if (!Array.isArray(blocks)) throw new TypeError('Worklog blocks must be an array.');
|
|
111
|
+
if (!blocks.length) return '';
|
|
112
|
+
return `${blocks.map((block) => [
|
|
113
|
+
`[ ${block.date} ]`,
|
|
114
|
+
...block.entries.flatMap((entry) => [
|
|
115
|
+
`${entry.number}. ${renderStatus(entry.status)} ${entry.title}`,
|
|
116
|
+
...(entry.notes || []).map((note) => `// ${note}`),
|
|
117
|
+
]),
|
|
118
|
+
].join('\n')).join('\n\n')}\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function cloneBlocks(blocks) {
|
|
122
|
+
return blocks.map((block) => ({
|
|
123
|
+
...block,
|
|
124
|
+
entries: block.entries.map((entry) => ({ ...entry, notes: [...entry.notes] })),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appendItem(blocks, title, status = 'pending', notes = [], date = normalizeDate()) {
|
|
129
|
+
const next = cloneBlocks(blocks);
|
|
130
|
+
const last = next[next.length - 1];
|
|
131
|
+
if (last && date < last.date) throw new Error(`Cannot append ${date}; the latest worklog date is ${last.date}.`);
|
|
132
|
+
if (last && last.date === date) {
|
|
133
|
+
last.entries.push({ number: last.entries.length + 1, title, status, notes: [...notes] });
|
|
134
|
+
} else {
|
|
135
|
+
next.push({ date, entries: [{ number: 1, title, status, notes: [...notes] }] });
|
|
136
|
+
}
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveReference(blocks, reference) {
|
|
141
|
+
const value = String(reference || '').trim();
|
|
142
|
+
const match = /^(?:([^#]+)#)?([1-9]\d*)$/.exec(value);
|
|
143
|
+
if (!match) throw new Error('Worklog item reference must be a number or YYYY-MM-DD#number.');
|
|
144
|
+
const date = match[1];
|
|
145
|
+
const number = Number(match[2]);
|
|
146
|
+
const block = date ? blocks.find((entry) => entry.date === normalizeDate(date)) : blocks[blocks.length - 1];
|
|
147
|
+
if (!block) throw new Error(`No worklog entries exist for ${date || 'the latest date'}.`);
|
|
148
|
+
const entry = block.entries.find((item) => item.number === number);
|
|
149
|
+
if (!entry) throw new Error(`Worklog item ${value} was not found.`);
|
|
150
|
+
return { block, entry };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function updateItem(blocks, reference, status, note) {
|
|
154
|
+
const next = cloneBlocks(blocks);
|
|
155
|
+
const resolved = resolveReference(next, reference);
|
|
156
|
+
resolved.entry.status = status;
|
|
157
|
+
if (note) resolved.entry.notes.push(note);
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function temporaryPath(destination) {
|
|
162
|
+
return path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeTextAtomic(destination, content) {
|
|
166
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
167
|
+
const temporary = temporaryPath(destination);
|
|
168
|
+
let descriptor;
|
|
169
|
+
try {
|
|
170
|
+
descriptor = fs.openSync(temporary, 'wx', 0o666);
|
|
171
|
+
fs.writeFileSync(descriptor, content, 'utf8');
|
|
172
|
+
fs.fsyncSync(descriptor);
|
|
173
|
+
fs.closeSync(descriptor);
|
|
174
|
+
descriptor = undefined;
|
|
175
|
+
fs.renameSync(temporary, destination);
|
|
176
|
+
} finally {
|
|
177
|
+
if (descriptor !== undefined) {
|
|
178
|
+
try { fs.closeSync(descriptor); } catch { /* Preserve the original failure. */ }
|
|
179
|
+
}
|
|
180
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* Best-effort cleanup. */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function removeStaleLock(lockPath, staleMs) {
|
|
185
|
+
try {
|
|
186
|
+
if (Date.now() - fs.lstatSync(lockPath).mtimeMs < staleMs) return false;
|
|
187
|
+
fs.rmSync(lockPath, { force: true });
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error && error.code === 'ENOENT') return true;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function acquireLock(destination, options = {}) {
|
|
196
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
197
|
+
const lockPath = `${destination}.lock`;
|
|
198
|
+
const timeoutMs = Number(options.lockTimeoutMs ?? 10_000);
|
|
199
|
+
const staleMs = Number(options.lockStaleMs ?? 60_000);
|
|
200
|
+
const retryMs = Number(options.lockRetryMs ?? 10);
|
|
201
|
+
if (![timeoutMs, staleMs, retryMs].every(Number.isSafeInteger) || timeoutMs < 0 || staleMs < 1 || retryMs < 1) {
|
|
202
|
+
throw new TypeError('Invalid worklog lock timing options.');
|
|
203
|
+
}
|
|
204
|
+
const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
|
|
205
|
+
const started = Date.now();
|
|
206
|
+
while (true) {
|
|
207
|
+
let descriptor;
|
|
208
|
+
try {
|
|
209
|
+
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
|
210
|
+
fs.writeFileSync(descriptor, `${token}\n`, 'utf8');
|
|
211
|
+
fs.fsyncSync(descriptor);
|
|
212
|
+
fs.closeSync(descriptor);
|
|
213
|
+
return { path: lockPath, token };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (descriptor !== undefined) {
|
|
216
|
+
try { fs.closeSync(descriptor); } catch { /* Preserve the original failure. */ }
|
|
217
|
+
}
|
|
218
|
+
if (!error || error.code !== 'EEXIST') throw error;
|
|
219
|
+
}
|
|
220
|
+
if (removeStaleLock(lockPath, staleMs)) continue;
|
|
221
|
+
const elapsed = Date.now() - started;
|
|
222
|
+
if (elapsed >= timeoutMs) {
|
|
223
|
+
const error = new Error('Timed out waiting for .draftgo/worklog.md.lock.');
|
|
224
|
+
error.code = 'WORKLOG_LOCK_TIMEOUT';
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
Atomics.wait(LOCK_WAIT_ARRAY, 0, 0, Math.min(retryMs, timeoutMs - elapsed));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function releaseLock(lock) {
|
|
232
|
+
try {
|
|
233
|
+
if (fs.readFileSync(lock.path, 'utf8').trim() === lock.token) fs.rmSync(lock.path, { force: true });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!error || error.code !== 'ENOENT') throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mutateWorklog(projectDir, mutate, options = {}) {
|
|
240
|
+
const file = worklogPath(projectDir);
|
|
241
|
+
const lock = acquireLock(file, options);
|
|
242
|
+
try {
|
|
243
|
+
const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
244
|
+
const blocks = parseWorklog(source);
|
|
245
|
+
const result = mutate(blocks);
|
|
246
|
+
const next = result && result.blocks ? result.blocks : result;
|
|
247
|
+
writeTextAtomic(file, renderWorklog(next));
|
|
248
|
+
return { ...(result && result.blocks ? result : { blocks: next }), path: file };
|
|
249
|
+
} finally {
|
|
250
|
+
releaseLock(lock);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readWorklog(projectDir) {
|
|
255
|
+
const file = worklogPath(projectDir);
|
|
256
|
+
return parseWorklog(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
STATUS,
|
|
261
|
+
formatLocalDate,
|
|
262
|
+
worklogPath,
|
|
263
|
+
normalizeDate,
|
|
264
|
+
parseWorklog,
|
|
265
|
+
renderWorklog,
|
|
266
|
+
appendItem,
|
|
267
|
+
resolveReference,
|
|
268
|
+
updateItem,
|
|
269
|
+
mutateWorklog,
|
|
270
|
+
readWorklog,
|
|
271
|
+
acquireLock,
|
|
272
|
+
releaseLock,
|
|
273
|
+
writeTextAtomic,
|
|
274
|
+
};
|
package/src/workspaceHealth.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const runtime = require('./runtimeFiles');
|
|
6
6
|
|
|
7
|
-
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', '
|
|
7
|
+
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'lessons/', 'tmp/', 'config.json', 'api-contract-cache.json', 'api-contract-cache.json.lock', 'worklog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
|
|
8
8
|
function workspaceHealth(projectDir) {
|
|
9
9
|
const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
|
|
10
10
|
const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
|
package/src/worktree/index.js
CHANGED
|
@@ -72,7 +72,12 @@ async function createSession(config, options) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function ensureIds(resourceIds) {
|
|
75
|
-
const
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const ids = resourceIds.map((value) => String(value).trim()).filter((value) => {
|
|
77
|
+
if (!value || seen.has(value)) return false;
|
|
78
|
+
seen.add(value);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
76
81
|
if (!ids.length) throw new WorktreeError('INVALID_RESOURCE_ID', 'At least one resource id is required.');
|
|
77
82
|
return ids;
|
|
78
83
|
}
|
|
@@ -95,9 +100,7 @@ async function checkoutResources(projectDir, resourceType, resourceIds, options
|
|
|
95
100
|
const backend = backendFor(options);
|
|
96
101
|
const session = await createSession(config, options);
|
|
97
102
|
const manifest = loadManifest(projectDir);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
for (const resourceId of ids) {
|
|
103
|
+
const settled = await Promise.allSettled(ids.map(async (resourceId) => {
|
|
101
104
|
const metadata = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
102
105
|
...options,
|
|
103
106
|
...session,
|
|
@@ -154,9 +157,28 @@ async function checkoutResources(projectDir, resourceType, resourceIds, options
|
|
|
154
157
|
updated_at: metadata.updated_at,
|
|
155
158
|
updated_by: metadata.updated_by,
|
|
156
159
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
return entry;
|
|
161
|
+
}));
|
|
162
|
+
const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
|
|
163
|
+
for (const entry of results) manifest.entries[entryKey(canonical, entry.resource_id)] = entry;
|
|
164
|
+
if (results.length) await saveManifest(projectDir, manifest);
|
|
165
|
+
const failures = settled.filter((item) => item.status === 'rejected');
|
|
166
|
+
if (failures.length) {
|
|
167
|
+
const first = failures[0].reason;
|
|
168
|
+
first.details = {
|
|
169
|
+
...(first.details || {}),
|
|
170
|
+
batch: {
|
|
171
|
+
completed: results.map((entry) => ({
|
|
172
|
+
resource_type: entry.resource_type, resource_id: entry.resource_id,
|
|
173
|
+
status: 'checked_out', local_path: entry.local_path,
|
|
174
|
+
})),
|
|
175
|
+
failed: failures.map((item) => ({
|
|
176
|
+
status: 'failed', code: item.reason.code || 'CHECKOUT_FAILED', message: item.reason.message,
|
|
177
|
+
})),
|
|
178
|
+
not_started: [],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
throw first;
|
|
160
182
|
}
|
|
161
183
|
return results;
|
|
162
184
|
}
|
|
@@ -264,9 +286,9 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
264
286
|
if (typeof options.onStatus === 'function') options.onStatus(result);
|
|
265
287
|
};
|
|
266
288
|
|
|
267
|
-
// Resolve and validate every target before the first remote write.
|
|
268
|
-
//
|
|
269
|
-
|
|
289
|
+
// Resolve and validate every target before the first remote write. Different
|
|
290
|
+
// resources are independent, so preflight can run without a client-side cap.
|
|
291
|
+
const preflight = await Promise.all(ids.map(async (resourceId) => {
|
|
270
292
|
try {
|
|
271
293
|
const entry = getEntry(manifest, canonical, resourceId);
|
|
272
294
|
if (!entry) {
|
|
@@ -312,7 +334,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
312
334
|
state,
|
|
313
335
|
);
|
|
314
336
|
}
|
|
315
|
-
|
|
337
|
+
return { plan: { resourceId, entry, localPath, current, fresh } };
|
|
316
338
|
} catch (error) {
|
|
317
339
|
if (error.code === 'REMOTE_VERSION_CHANGED') {
|
|
318
340
|
const entry = getEntry(manifest, canonical, resourceId);
|
|
@@ -327,7 +349,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
327
349
|
error = conflictError;
|
|
328
350
|
}
|
|
329
351
|
}
|
|
330
|
-
|
|
352
|
+
return { failure: {
|
|
331
353
|
resource_type: canonical,
|
|
332
354
|
resource_id: resourceId,
|
|
333
355
|
status: 'failed',
|
|
@@ -335,8 +357,12 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
335
357
|
code: error.code || 'PREFLIGHT_FAILED',
|
|
336
358
|
message: error.message,
|
|
337
359
|
details: error.details || {},
|
|
338
|
-
}
|
|
360
|
+
} };
|
|
339
361
|
}
|
|
362
|
+
}));
|
|
363
|
+
for (const item of preflight) {
|
|
364
|
+
if (item.plan) plans.push(item.plan);
|
|
365
|
+
else preflightFailures.push(item.failure);
|
|
340
366
|
}
|
|
341
367
|
|
|
342
368
|
if (preflightFailures.length) {
|
|
@@ -363,40 +389,10 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
363
389
|
);
|
|
364
390
|
}
|
|
365
391
|
|
|
366
|
-
function
|
|
367
|
-
const
|
|
368
|
-
resource_type: canonical,
|
|
369
|
-
resource_id: resourceId,
|
|
370
|
-
status: 'failed',
|
|
371
|
-
phase: 'upload',
|
|
372
|
-
code: error.code || 'COMMIT_FAILED',
|
|
373
|
-
message: error.message,
|
|
374
|
-
remote_change_possible: remoteChangePossible,
|
|
375
|
-
};
|
|
376
|
-
const notStarted = plans.slice(index + 1).map((plan) => ({
|
|
377
|
-
resource_type: canonical,
|
|
378
|
-
resource_id: plan.resourceId,
|
|
379
|
-
status: 'not_started',
|
|
380
|
-
phase: 'upload',
|
|
381
|
-
}));
|
|
382
|
-
report(failed);
|
|
383
|
-
for (const item of notStarted) report(item);
|
|
384
|
-
error.details = {
|
|
385
|
-
...(error.details || {}),
|
|
386
|
-
batch: {
|
|
387
|
-
completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
|
|
388
|
-
failed: [failed],
|
|
389
|
-
not_started: notStarted,
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
throw error;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
for (let index = 0; index < plans.length; index += 1) {
|
|
396
|
-
const { resourceId, entry, localPath, current, fresh } = plans[index];
|
|
392
|
+
async function commitPlan(plan) {
|
|
393
|
+
const { resourceId, entry, localPath, current, fresh } = plan;
|
|
397
394
|
if (current.hash === entry.base_hash) {
|
|
398
|
-
|
|
399
|
-
continue;
|
|
395
|
+
return { resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash };
|
|
400
396
|
}
|
|
401
397
|
const commitMetadata = {
|
|
402
398
|
...fresh,
|
|
@@ -420,7 +416,12 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
420
416
|
conflict,
|
|
421
417
|
);
|
|
422
418
|
}
|
|
423
|
-
|
|
419
|
+
failure.batchResult = {
|
|
420
|
+
resource_type: canonical, resource_id: resourceId, status: 'failed', phase: 'upload',
|
|
421
|
+
code: failure.code || 'COMMIT_FAILED', message: failure.message,
|
|
422
|
+
remote_change_possible: !versionConflict,
|
|
423
|
+
};
|
|
424
|
+
throw failure;
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
try {
|
|
@@ -458,18 +459,47 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
458
459
|
entry.updated_at = committed.updated_at;
|
|
459
460
|
entry.updated_by = committed.updated_by;
|
|
460
461
|
entry.committed_at = new Date().toISOString();
|
|
461
|
-
|
|
462
|
-
report({
|
|
462
|
+
return {
|
|
463
463
|
resource_type: canonical,
|
|
464
464
|
resource_id: resourceId,
|
|
465
465
|
status: 'committed',
|
|
466
466
|
hash: current.hash,
|
|
467
467
|
base_version: entry.base_version,
|
|
468
468
|
base_revision: entry.base_revision,
|
|
469
|
-
}
|
|
469
|
+
};
|
|
470
|
+
} catch (error) {
|
|
471
|
+
error.batchResult = {
|
|
472
|
+
resource_type: canonical, resource_id: resourceId, status: 'failed', phase: 'upload',
|
|
473
|
+
code: error.code || 'COMMIT_FAILED', message: error.message, remote_change_possible: true,
|
|
474
|
+
};
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const settled = await Promise.allSettled(plans.map(async (plan) => {
|
|
480
|
+
try {
|
|
481
|
+
const result = await commitPlan(plan);
|
|
482
|
+
report(result);
|
|
483
|
+
return result;
|
|
470
484
|
} catch (error) {
|
|
471
|
-
|
|
485
|
+
report(error.batchResult);
|
|
486
|
+
throw error;
|
|
472
487
|
}
|
|
488
|
+
}));
|
|
489
|
+
const committed = results.filter((item) => item.status === 'committed');
|
|
490
|
+
if (committed.length) await saveManifest(projectDir, manifest);
|
|
491
|
+
const failures = settled.filter((item) => item.status === 'rejected');
|
|
492
|
+
if (failures.length) {
|
|
493
|
+
const first = failures[0].reason;
|
|
494
|
+
first.details = {
|
|
495
|
+
...(first.details || {}),
|
|
496
|
+
batch: {
|
|
497
|
+
completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
|
|
498
|
+
failed: results.filter((item) => item.status === 'failed'),
|
|
499
|
+
not_started: [],
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
throw first;
|
|
473
503
|
}
|
|
474
504
|
return results;
|
|
475
505
|
}
|