arcane-os 0.96.0 → 0.97.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/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-model-assets.mjs +5 -0
- package/docs/reference/README.md +5 -0
- package/docs/reference/core-native-packaging.md +13 -3
- package/docs/reference/inventory/package-api.json +111 -15
- package/docs/reference/model-assets.md +71 -29
- package/docs/reference/native-speech.md +30 -7
- package/docs/reference/sdk-api.md +229 -15
- package/package.json +1 -1
- package/src/core/host.mjs +22 -18
- package/src/core/services/model-assets.mjs +461 -94
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
|
|
1
|
+
import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile} from 'node:fs/promises';
|
|
2
|
+
import {watch} from 'node:fs';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
2
4
|
import {Buffer} from 'node:buffer';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import Is from 'strong-type';
|
|
@@ -146,11 +148,16 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
|
|
|
146
148
|
catch (error) { if (error.code !== 'ENOENT') throw error; linked.set(entry, null); }
|
|
147
149
|
}
|
|
148
150
|
let acquisition;
|
|
151
|
+
let acquisitionComplete = false;
|
|
149
152
|
if (path.basename(directory).startsWith('acquisition-')) {
|
|
150
|
-
try { acquisition = JSON.parse(await readFile(path.join(directory, 'acquisition.json'), {encoding: 'utf8', signal})); }
|
|
151
|
-
catch (error) {
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
try { acquisition = JSON.parse(await readFile(path.join(directory, 'acquisition.json'), {encoding: 'utf8', signal})); acquisitionComplete = true; }
|
|
154
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
155
|
+
if (!acquisition) {
|
|
156
|
+
try { acquisition = JSON.parse(await readFile(path.join(directory, 'transfer.json'), {encoding: 'utf8', signal})); }
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== 'ENOENT') throw error;
|
|
159
|
+
if (path.basename(path.dirname(directory)) === 'model-assets') return;
|
|
160
|
+
}
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
if (acquisition) {
|
|
@@ -164,7 +171,9 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
|
|
|
164
171
|
&& (member.archiveMember === undefined || member.archiveMember === original.archiveMember);
|
|
165
172
|
});
|
|
166
173
|
});
|
|
167
|
-
|
|
174
|
+
const record = discoveryRecord(files, members, 'acquisition', selection, discoveryEntry(groups, selection?.entryPath));
|
|
175
|
+
if (!acquisitionComplete) record.complete = false;
|
|
176
|
+
add(record);
|
|
168
177
|
return;
|
|
169
178
|
}
|
|
170
179
|
for (const selection of selections) {
|
|
@@ -193,31 +202,198 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
|
|
|
193
202
|
return {directories: roots, models};
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
async function
|
|
197
|
-
|
|
205
|
+
async function storedAcquisitions(directory, members, signal) {
|
|
206
|
+
const acquisitions = [];
|
|
198
207
|
for (const entry of await readdir(directory, {withFileTypes: true})) {
|
|
199
208
|
signal.throwIfAborted();
|
|
200
209
|
if (!entry.isDirectory() || !entry.name.startsWith('acquisition-')) continue;
|
|
201
210
|
const root = path.join(directory, entry.name);
|
|
202
211
|
let selected;
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
let committed = false;
|
|
213
|
+
try { selected = JSON.parse(await readFile(path.join(root, 'acquisition.json'), {encoding: 'utf8', signal})); committed = true; }
|
|
214
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
215
|
+
if (!selected) {
|
|
216
|
+
try { selected = JSON.parse(await readFile(path.join(root, 'transfer.json'), {encoding: 'utf8', signal})); }
|
|
217
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
218
|
+
}
|
|
205
219
|
if (!sameMembers(selected.members, members)) continue;
|
|
206
|
-
nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order);
|
|
220
|
+
nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order ?? 0);
|
|
207
221
|
const files = path.join(root, 'files');
|
|
208
222
|
const locations = members.map(function originalLocation(member) { return memberLocation(files, member); });
|
|
209
|
-
|
|
210
|
-
for (const member of locations) {
|
|
223
|
+
const completedMembers = [];
|
|
224
|
+
for (const [index, member] of locations.entries()) {
|
|
225
|
+
signal.throwIfAborted();
|
|
226
|
+
// Only an atomically promoted final filename is a completed member
|
|
227
|
+
// in the resumable layout. Older completion records keep their meaning.
|
|
228
|
+
try { if ((await stat(member.nativePath)).isFile()) completedMembers.push(index); }
|
|
229
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
230
|
+
}
|
|
231
|
+
acquisitions.push({root, name: entry.name, order: selected.order ?? 0, refresh: selected.refresh === true,
|
|
232
|
+
complete: committed && completedMembers.length === members.length, completedMembers, directory: files, members: locations});
|
|
233
|
+
}
|
|
234
|
+
acquisitions.sort(function newestAcquisition(left, right) { return right.order - left.order || right.name.localeCompare(left.name); });
|
|
235
|
+
return acquisitions;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function saveAcquisitionState(root, name, record) {
|
|
239
|
+
const pending = path.join(root, `${name}.${randomUUID()}.pending`);
|
|
240
|
+
await writeFile(pending, JSON.stringify(record), {encoding: 'utf8', flag: 'wx'});
|
|
241
|
+
await rename(pending, path.join(root, name));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function processExists(pid) {
|
|
245
|
+
try { process.kill(pid, 0); return true; }
|
|
246
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Atomic directory publication keeps an owner record visible with its lock. */
|
|
250
|
+
async function acquireDirectoryOwner(directory, name, signal, waiting) {
|
|
251
|
+
const lock = path.join(directory, name);
|
|
252
|
+
const candidate = await mkdtemp(path.join(directory, `${name}-candidate-`));
|
|
253
|
+
const owner = {pid: process.pid, token: randomUUID(), acquiredAt: new Date().toISOString(),
|
|
254
|
+
scope: lock, expires: 'When the recorded process exits; live ownership has no elapsed-time expiry.',
|
|
255
|
+
release: 'Retire the owned directory atomically after owned I/O settles, then remove its own metadata.',
|
|
256
|
+
recovery: 'A contender may recover this directory only after its recorded process has exited.'};
|
|
257
|
+
let acquired = false;
|
|
258
|
+
async function retireOwner(selectedLock) {
|
|
259
|
+
const retired = path.join(directory, `${name}-released-${randomUUID()}`);
|
|
260
|
+
await rename(selectedLock, retired);
|
|
261
|
+
await unlink(path.join(retired, 'owner.json'));
|
|
262
|
+
await rmdir(retired);
|
|
263
|
+
}
|
|
264
|
+
let acquisitionError;
|
|
265
|
+
try {
|
|
266
|
+
await writeFile(path.join(candidate, 'owner.json'), JSON.stringify(owner), {encoding: 'utf8', flag: 'wx'});
|
|
267
|
+
while (!acquired) {
|
|
211
268
|
signal.throwIfAborted();
|
|
212
|
-
|
|
213
|
-
|
|
269
|
+
let notify;
|
|
270
|
+
let rejectWait;
|
|
271
|
+
const changed = new Promise(function observeOwnerChange(resolve, reject) { notify = resolve; rejectWait = reject; });
|
|
272
|
+
changed.catch(function observeWaitRejection() {});
|
|
273
|
+
const watcher = watch(directory, function ownerChanged() { notify(); });
|
|
274
|
+
watcher.on('error', rejectWait);
|
|
275
|
+
// A crashed process emits no filesystem event. This heartbeat checks
|
|
276
|
+
// that exact owner, and also keeps a live wait observable to callers.
|
|
277
|
+
const heartbeat = setTimeout(notify, 5000);
|
|
278
|
+
heartbeat.unref?.();
|
|
279
|
+
function aborted() { rejectWait(signal.reason); }
|
|
280
|
+
signal.addEventListener('abort', aborted, {once: true});
|
|
281
|
+
try {
|
|
282
|
+
try { await rename(candidate, lock); acquired = true; }
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
|
|
285
|
+
let active;
|
|
286
|
+
try { active = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); }
|
|
287
|
+
catch (readError) {
|
|
288
|
+
if (readError.code !== 'ENOENT') throw readError;
|
|
289
|
+
try {
|
|
290
|
+
// Empty ownerless directories can remain from an
|
|
291
|
+
// older interrupted release. rmdir cannot remove a
|
|
292
|
+
// newly published nonempty owner directory.
|
|
293
|
+
await rmdir(lock);
|
|
294
|
+
continue;
|
|
295
|
+
} catch (emptyError) {
|
|
296
|
+
if (emptyError.code === 'ENOENT') {
|
|
297
|
+
if (error.code === 'EPERM' || error.code === 'EACCES') throw error;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!['ENOTEMPTY', 'EEXIST'].includes(emptyError.code)) throw emptyError;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (active && !processExists(active.pid)) {
|
|
304
|
+
let current;
|
|
305
|
+
try { current = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); }
|
|
306
|
+
catch (readError) { if (readError.code === 'ENOENT') continue; throw readError; }
|
|
307
|
+
// The old process may have released normally and then
|
|
308
|
+
// exited after our first read. Do not retire its successor.
|
|
309
|
+
if (current.token !== active.token) continue;
|
|
310
|
+
// Keep this nonempty operation record after recovery.
|
|
311
|
+
// A delayed contender for the same dead owner cannot
|
|
312
|
+
// replace it with a newly published live owner's lock.
|
|
313
|
+
const retired = path.join(directory, `${name}-exited-${active.token}`);
|
|
314
|
+
try { await rename(lock, retired); }
|
|
315
|
+
catch (recoveryError) {
|
|
316
|
+
if (recoveryError.code === 'ENOENT') continue;
|
|
317
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(recoveryError.code)) throw recoveryError;
|
|
318
|
+
const recovered = JSON.parse(await readFile(path.join(retired, 'owner.json'), 'utf8'));
|
|
319
|
+
if (recovered.token !== active.token) throw recoveryError;
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
await waiting?.(active);
|
|
324
|
+
signal.throwIfAborted();
|
|
325
|
+
await changed;
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
clearTimeout(heartbeat);
|
|
329
|
+
watcher.close();
|
|
330
|
+
signal.removeEventListener('abort', aborted);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (error) { acquisitionError = error; throw error; }
|
|
334
|
+
finally {
|
|
335
|
+
if (!acquired) {
|
|
336
|
+
try {
|
|
337
|
+
try { await unlink(path.join(candidate, 'owner.json')); }
|
|
338
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
339
|
+
await rmdir(candidate);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Acquiring model ownership and releasing its candidate failed.');
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return async function releaseDirectoryOwner() {
|
|
347
|
+
const current = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
|
348
|
+
if (current.token !== owner.token) throw failure('MODEL_ASSET_OWNER_CHANGED', 'Model acquisition ownership changed before release.');
|
|
349
|
+
await retireOwner(lock);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function retainRestartedPart(attempt, memberIndex) {
|
|
354
|
+
const partial = path.join(attempt, 'parts', `${memberIndex}.part`);
|
|
355
|
+
const previous = path.join(attempt, 'parts', `${memberIndex}.previous.part`);
|
|
356
|
+
try { await stat(previous); }
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error.code !== 'ENOENT') throw error;
|
|
359
|
+
try { await rename(partial, previous); return; }
|
|
360
|
+
catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; return; }
|
|
361
|
+
}
|
|
362
|
+
// Retain the earlier resumable original while replacing only this member's
|
|
363
|
+
// failed replacement. Repeated retries do not accumulate duplicate parts.
|
|
364
|
+
try { await unlink(partial); }
|
|
365
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function selectNativeAcquisition(entry, progress) {
|
|
369
|
+
const {directory, members, controller, refresh} = entry;
|
|
370
|
+
const {signal} = controller;
|
|
371
|
+
const release = await acquireDirectoryOwner(directory, '.selection-owner', signal, function waitingForSelection() { progress('waiting'); });
|
|
372
|
+
let selectionError;
|
|
373
|
+
try {
|
|
374
|
+
const stored = await storedAcquisitions(directory, members, signal);
|
|
375
|
+
if (!refresh) {
|
|
376
|
+
const complete = stored.find(function completeAcquisition(candidate) { return candidate.complete; });
|
|
377
|
+
if (complete) return complete;
|
|
214
378
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
379
|
+
const resumable = stored.find(function incompleteAcquisition(candidate) { return !candidate.complete && (!refresh || candidate.refresh); });
|
|
380
|
+
if (resumable) return resumable;
|
|
381
|
+
const root = await mkdtemp(path.join(directory, 'acquisition-'));
|
|
382
|
+
const files = path.join(root, 'files');
|
|
383
|
+
await mkdir(files);
|
|
384
|
+
await mkdir(path.join(root, 'parts'));
|
|
385
|
+
const order = ++nativeAcquisitionOrder;
|
|
386
|
+
await saveAcquisitionState(root, 'transfer.json', {members, order, refresh, completedMembers: [], transports: {}});
|
|
387
|
+
return {root, order, refresh, complete: false, completedMembers: [], directory: files,
|
|
388
|
+
members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
|
|
389
|
+
} catch (error) { selectionError = error; throw error; }
|
|
390
|
+
finally {
|
|
391
|
+
try { await release(); }
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (selectionError) throw new AggregateError([selectionError, error], 'Selecting model assets and releasing selection ownership failed.');
|
|
394
|
+
throw error;
|
|
218
395
|
}
|
|
219
396
|
}
|
|
220
|
-
return latest ? {directory: latest.directory, members: latest.members} : null;
|
|
221
397
|
}
|
|
222
398
|
|
|
223
399
|
async function fetchModelResponse(url, signal, range) {
|
|
@@ -287,7 +463,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
287
463
|
if (!pending.length && !ended) {
|
|
288
464
|
const chunk = await reader.read();
|
|
289
465
|
ended = chunk.done;
|
|
290
|
-
if (!ended) pending = Buffer.from(chunk.value);
|
|
466
|
+
if (!ended) { pending = Buffer.from(chunk.value); entry.activity(); }
|
|
291
467
|
}
|
|
292
468
|
if (!pending.length && ended) {
|
|
293
469
|
if (allowEnd && remaining === length) return false;
|
|
@@ -327,16 +503,16 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
327
503
|
if (range?.stopBefore && (memberName === range.stopBefore || path.posix.basename(memberName) === range.stopBefore)) break;
|
|
328
504
|
const memberIndexes = selected.get(memberName);
|
|
329
505
|
if (memberIndexes && (type === '0' || type === '\0')) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
506
|
+
// TAR extraction is ordered: a later complete occurrence replaces
|
|
507
|
+
// the earlier file only after its own stream and file close.
|
|
333
508
|
const handles = [];
|
|
334
509
|
const errors = [];
|
|
335
510
|
try {
|
|
336
511
|
for (const memberIndex of memberIndexes) {
|
|
337
512
|
const location = entry.location.members[memberIndex];
|
|
338
513
|
await mkdir(path.dirname(location.nativePath), {recursive: true});
|
|
339
|
-
|
|
514
|
+
const partial = await entry.archivePart(memberIndex);
|
|
515
|
+
handles.push(await open(partial, 'wx'));
|
|
340
516
|
progress('download', memberIndex);
|
|
341
517
|
}
|
|
342
518
|
await consume(length, async function originalMemberContent(content) {
|
|
@@ -352,7 +528,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
352
528
|
if (errors.length) throw new AggregateError(errors, 'Extracting a model member and closing its files failed.');
|
|
353
529
|
for (const memberIndex of memberIndexes) {
|
|
354
530
|
found.add(memberIndex);
|
|
355
|
-
completeMember(memberIndex);
|
|
531
|
+
await completeMember(memberIndex);
|
|
356
532
|
}
|
|
357
533
|
} else await consume(length);
|
|
358
534
|
await consume((512 - length % 512) % 512);
|
|
@@ -365,98 +541,267 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
365
541
|
if (operationError) throw operationError;
|
|
366
542
|
}
|
|
367
543
|
|
|
544
|
+
async function downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure) {
|
|
545
|
+
const member = entry.members[memberIndex];
|
|
546
|
+
const {signal} = entry.controller;
|
|
547
|
+
const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
|
|
548
|
+
let offset = 0;
|
|
549
|
+
try { offset = (await stat(partial)).size; }
|
|
550
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
551
|
+
const previous = state.transports[memberIndex];
|
|
552
|
+
let response;
|
|
553
|
+
let reader;
|
|
554
|
+
let handle;
|
|
555
|
+
let cancellation;
|
|
556
|
+
let ended = false;
|
|
557
|
+
let operationError;
|
|
558
|
+
let remaining;
|
|
559
|
+
|
|
560
|
+
async function restart(reason) {
|
|
561
|
+
progress('restart', memberIndex, false, {reason});
|
|
562
|
+
await retainRestartedPart(entry.attempt, memberIndex);
|
|
563
|
+
offset = 0;
|
|
564
|
+
}
|
|
565
|
+
async function request(headers) {
|
|
566
|
+
try { return await fetch(member.url, {signal, headers}); }
|
|
567
|
+
catch (error) { noteFailure(error, 'interrupted'); throw error; }
|
|
568
|
+
}
|
|
569
|
+
function cancelReader() {
|
|
570
|
+
if (!reader || ended) return;
|
|
571
|
+
cancellation ??= reader.cancel(signal.reason);
|
|
572
|
+
cancellation.catch(function observeReaderCancellation() {});
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
signal.throwIfAborted();
|
|
576
|
+
if (offset && previous?.encoding && previous.encoding !== 'identity') await restart('The previous response used encoded HTTP content and cannot be appended by Range.');
|
|
577
|
+
const validator = previous?.etag && !previous.etag.startsWith('W/') ? previous.etag : previous?.lastModified;
|
|
578
|
+
if (offset && !validator) await restart('The source supplied no HTTP validator for continuing its earlier response.');
|
|
579
|
+
const headers = {'Accept-Encoding': 'identity'};
|
|
580
|
+
if (offset) {
|
|
581
|
+
headers.Range = `bytes=${offset}-`;
|
|
582
|
+
headers['If-Range'] = validator;
|
|
583
|
+
progress('resume', memberIndex);
|
|
584
|
+
} else progress('download', memberIndex);
|
|
585
|
+
response = await request(headers);
|
|
586
|
+
signal.throwIfAborted();
|
|
587
|
+
if (offset && response.status === 416) {
|
|
588
|
+
await response.body?.cancel();
|
|
589
|
+
response = undefined;
|
|
590
|
+
await restart('The server no longer accepts this saved HTTP range.');
|
|
591
|
+
response = await request({'Accept-Encoding': 'identity'});
|
|
592
|
+
}
|
|
593
|
+
if (!response.ok) {
|
|
594
|
+
const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
|
|
595
|
+
error.url = member.url;
|
|
596
|
+
error.status = response.status;
|
|
597
|
+
error.response = await response.text();
|
|
598
|
+
noteFailure(error, response.status >= 500 || response.status === 408 || response.status === 429 ? 'interrupted' : 'failed');
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
|
|
602
|
+
if (offset && response.status === 200) await restart('The server returned the complete file instead of the saved HTTP range.');
|
|
603
|
+
if (response.status === 206) {
|
|
604
|
+
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
|
|
605
|
+
const encoding = response.headers.get('content-encoding');
|
|
606
|
+
const validatorHeader = previous?.etag && !previous.etag.startsWith('W/') ? 'etag' : 'last-modified';
|
|
607
|
+
const currentValidator = response.headers.get(validatorHeader);
|
|
608
|
+
const changed = currentValidator && currentValidator !== validator;
|
|
609
|
+
if (!offset || !range || Number(range[1]) !== offset || Number(range[2]) < offset
|
|
610
|
+
|| Number(range[2]) + 1 !== Number(range[3]) || (encoding && encoding !== 'identity') || changed) {
|
|
611
|
+
const error = failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server did not return the requested continuation of this model file.');
|
|
612
|
+
error.url = member.url;
|
|
613
|
+
error.status = response.status;
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
// Content-Range framing is private to this HTTP transfer. It never
|
|
617
|
+
// participates in model identity, admission or user-facing progress.
|
|
618
|
+
remaining = Number(range[2]) - Number(range[1]) + 1;
|
|
619
|
+
}
|
|
620
|
+
state.transports[memberIndex] = {
|
|
621
|
+
etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'),
|
|
622
|
+
encoding: response.headers.get('content-encoding')
|
|
623
|
+
};
|
|
624
|
+
await save();
|
|
625
|
+
handle = await open(partial, 'a');
|
|
626
|
+
reader = response.body.getReader();
|
|
627
|
+
signal.addEventListener('abort', cancelReader, {once: true});
|
|
628
|
+
if (signal.aborted) cancelReader();
|
|
629
|
+
while (true) {
|
|
630
|
+
signal.throwIfAborted();
|
|
631
|
+
let chunk;
|
|
632
|
+
try { chunk = await reader.read(); }
|
|
633
|
+
catch (error) { noteFailure(error, 'interrupted'); throw error; }
|
|
634
|
+
signal.throwIfAborted();
|
|
635
|
+
if (chunk.done) { ended = true; break; }
|
|
636
|
+
if (remaining !== undefined) {
|
|
637
|
+
remaining -= chunk.value.byteLength;
|
|
638
|
+
if (remaining < 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server exceeded its HTTP Content-Range.');
|
|
639
|
+
}
|
|
640
|
+
await handle.appendFile(chunk.value);
|
|
641
|
+
progress('download', memberIndex, true);
|
|
642
|
+
}
|
|
643
|
+
if (remaining !== undefined && remaining !== 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server ended before its HTTP Content-Range.');
|
|
644
|
+
signal.throwIfAborted();
|
|
645
|
+
} catch (error) { operationError = error; noteFailure(error, 'failed'); }
|
|
646
|
+
signal.removeEventListener('abort', cancelReader);
|
|
647
|
+
try {
|
|
648
|
+
if (reader && !ended) { cancellation ??= reader.cancel(operationError); await cancellation; }
|
|
649
|
+
else if (!reader && response?.body && !response.bodyUsed && !response.body.locked) await response.body.cancel(operationError);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error !== operationError) operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its response failed.') : error;
|
|
652
|
+
}
|
|
653
|
+
try { reader?.releaseLock(); }
|
|
654
|
+
catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and releasing its reader failed.') : error; }
|
|
655
|
+
try { await handle?.close(); }
|
|
656
|
+
catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its file failed.') : error; }
|
|
657
|
+
if (operationError) { noteFailure(operationError, 'failed'); throw operationError; }
|
|
658
|
+
await completeMember(memberIndex);
|
|
659
|
+
}
|
|
660
|
+
|
|
368
661
|
async function acquireNativeMembers(entry) {
|
|
369
|
-
const {
|
|
662
|
+
const {members, controller} = entry;
|
|
370
663
|
const {signal} = controller;
|
|
371
|
-
let attempt;
|
|
372
664
|
let completed = 0;
|
|
373
|
-
let committed = false;
|
|
374
665
|
let firstFailure;
|
|
666
|
+
let failureState;
|
|
667
|
+
let releaseOwner;
|
|
668
|
+
let state;
|
|
669
|
+
let stateTail = Promise.resolve();
|
|
670
|
+
let heartbeat;
|
|
671
|
+
let acquisitionError;
|
|
672
|
+
let lastActivity = Date.now();
|
|
673
|
+
entry.activity = function receivedTransportContent() { lastActivity = Date.now(); };
|
|
375
674
|
const activity = new Map();
|
|
376
|
-
function
|
|
675
|
+
function noteFailure(error, selectedState) {
|
|
676
|
+
if (firstFailure || signal.aborted) return;
|
|
677
|
+
firstFailure = error;
|
|
678
|
+
failureState = selectedState;
|
|
679
|
+
controller.abort(error);
|
|
680
|
+
}
|
|
681
|
+
function save() {
|
|
682
|
+
const next = stateTail.then(function saveTransferState() { return saveAcquisitionState(entry.attempt, 'transfer.json', state); });
|
|
683
|
+
stateTail = next.catch(function observeStateFailure(error) { noteFailure(error, 'failed'); });
|
|
684
|
+
return next;
|
|
685
|
+
}
|
|
686
|
+
function progress(phase, memberIndex, repeatedActivity = false, details = {}) {
|
|
687
|
+
if (phase === 'download') lastActivity = Date.now();
|
|
377
688
|
if (repeatedActivity) {
|
|
378
689
|
const now = Date.now();
|
|
379
690
|
if (now - (activity.get(memberIndex) ?? 0) < 1000) return;
|
|
380
691
|
activity.set(memberIndex, now);
|
|
381
692
|
}
|
|
382
|
-
|
|
383
|
-
|
|
693
|
+
const selectedState = details.state ?? ({waiting: 'waiting', stalled: 'stalled', interrupted: 'interrupted',
|
|
694
|
+
cancelled: 'cancelled', failed: 'failed', ready: 'ready'})[phase] ?? 'downloading';
|
|
695
|
+
const messages = {
|
|
696
|
+
waiting: 'Another application is preparing these model files. Waiting for its acquisition.',
|
|
697
|
+
downloading: 'Downloading model files.', stalled: 'The model download is still open; no recent data has arrived.',
|
|
698
|
+
interrupted: 'The model download was interrupted. Completed files and resumable data were kept.',
|
|
699
|
+
cancelled: 'The model download was cancelled. Completed files and resumable data were kept.',
|
|
700
|
+
failed: 'The model download failed. Existing files and resumable data were kept.', ready: 'Model files are ready.'
|
|
701
|
+
};
|
|
702
|
+
const notification = {phase, state: selectedState, message: phase === 'resume' ? 'Resuming the saved model download.'
|
|
703
|
+
: phase === 'restart' ? 'Restarting this model file because its saved HTTP continuation is unavailable. Earlier partial data was retained.'
|
|
704
|
+
: phase === 'reuse' ? 'Reusing downloaded model files.' : messages[selectedState],
|
|
705
|
+
completed, total: members.length, unit: 'files',
|
|
706
|
+
...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path}), ...details};
|
|
707
|
+
if (['interrupted', 'cancelled', 'failed'].includes(selectedState)) notification.recovery = 'Retry to reuse completed files and continue saved downloads.';
|
|
708
|
+
if (selectedState === 'stalled') notification.recovery = 'Wait for the source to respond, or cancel and retry later.';
|
|
709
|
+
if (state) state.progress = notification;
|
|
710
|
+
entry.publish(notification);
|
|
384
711
|
}
|
|
385
712
|
try {
|
|
386
713
|
signal.throwIfAborted();
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
714
|
+
const selected = await selectNativeAcquisition(entry, progress);
|
|
715
|
+
entry.attempt = selected.root;
|
|
716
|
+
entry.location = {directory: selected.directory, members: selected.members};
|
|
717
|
+
if (selected.complete) {
|
|
390
718
|
completed = members.length;
|
|
391
719
|
progress('reuse');
|
|
392
720
|
progress('ready');
|
|
393
|
-
return
|
|
721
|
+
return entry.location;
|
|
394
722
|
}
|
|
395
723
|
signal.throwIfAborted();
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
724
|
+
releaseOwner = await acquireDirectoryOwner(selected.root, '.transfer-owner', signal, async function waitingForTransfer() {
|
|
725
|
+
try {
|
|
726
|
+
const observed = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8'));
|
|
727
|
+
completed = observed.completedMembers?.length ?? 0;
|
|
728
|
+
progress('waiting', undefined, false, {activity: observed.progress});
|
|
729
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; progress('waiting'); }
|
|
730
|
+
});
|
|
731
|
+
const current = (await storedAcquisitions(entry.directory, members, signal)).find(function ownedAttempt(candidate) { return candidate.root === selected.root; });
|
|
732
|
+
if (current?.complete) {
|
|
733
|
+
completed = members.length;
|
|
734
|
+
progress('reuse');
|
|
735
|
+
progress('ready');
|
|
736
|
+
return entry.location;
|
|
737
|
+
}
|
|
738
|
+
try { state = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8')); }
|
|
739
|
+
catch (error) {
|
|
740
|
+
if (error.code !== 'ENOENT') throw error;
|
|
741
|
+
state = {members, order: selected.order, refresh: selected.refresh, transports: {}};
|
|
742
|
+
}
|
|
743
|
+
state.transports ??= {};
|
|
744
|
+
state.completedMembers = current?.completedMembers ?? [];
|
|
745
|
+
const finished = new Set(state.completedMembers);
|
|
746
|
+
const completedArchiveMembers = new Set(state.completedArchiveMembers ?? []);
|
|
747
|
+
completed = finished.size;
|
|
748
|
+
await mkdir(path.join(selected.root, 'parts'), {recursive: true});
|
|
400
749
|
progress('open');
|
|
401
|
-
|
|
402
|
-
|
|
750
|
+
await save();
|
|
751
|
+
heartbeat = setInterval(function reportTransferHeartbeat() {
|
|
752
|
+
const stalled = Date.now() - lastActivity >= 15000;
|
|
753
|
+
progress(stalled ? 'stalled' : 'heartbeat');
|
|
754
|
+
save().catch(function observeHeartbeatFailure(error) { noteFailure(error, 'failed'); });
|
|
755
|
+
}, 5000);
|
|
756
|
+
heartbeat.unref?.();
|
|
757
|
+
async function completeMember(memberIndex) {
|
|
758
|
+
const location = entry.location.members[memberIndex];
|
|
759
|
+
await mkdir(path.dirname(location.nativePath), {recursive: true});
|
|
760
|
+
await rename(path.join(entry.attempt, 'parts', `${memberIndex}.part`), location.nativePath);
|
|
761
|
+
finished.add(memberIndex);
|
|
762
|
+
state.completedMembers = [...finished];
|
|
763
|
+
completed = finished.size;
|
|
764
|
+
await save();
|
|
765
|
+
try { await unlink(path.join(entry.attempt, 'parts', `${memberIndex}.previous.part`)); }
|
|
766
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
403
767
|
progress('download', memberIndex);
|
|
404
768
|
}
|
|
769
|
+
entry.archivePart = async function prepareArchivePart(memberIndex) {
|
|
770
|
+
const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
|
|
771
|
+
try {
|
|
772
|
+
await stat(partial);
|
|
773
|
+
await retainRestartedPart(entry.attempt, memberIndex);
|
|
774
|
+
progress('restart', memberIndex, false, {reason: 'The selected TAR segment must be replayed to recover its member framing.'});
|
|
775
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
776
|
+
return partial;
|
|
777
|
+
};
|
|
778
|
+
if (completed) progress('reuse');
|
|
405
779
|
const jobs = members.map(function ordinaryJob(member, memberIndex) {
|
|
406
|
-
if (member.archiveMember !== undefined) return null;
|
|
780
|
+
if (member.archiveMember !== undefined || finished.has(memberIndex)) return null;
|
|
407
781
|
return async function downloadMember() {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
let downloadError;
|
|
411
|
-
const failures = [];
|
|
412
|
-
let bodyOwned = false;
|
|
413
|
-
try {
|
|
414
|
-
signal.throwIfAborted();
|
|
415
|
-
const nativePath = entry.location.members[memberIndex].nativePath;
|
|
416
|
-
await mkdir(path.dirname(nativePath), {recursive: true});
|
|
417
|
-
handle = await open(nativePath, 'wx');
|
|
418
|
-
signal.throwIfAborted();
|
|
419
|
-
progress('download', memberIndex);
|
|
420
|
-
response = await fetchModelResponse(member.url, signal);
|
|
421
|
-
bodyOwned = true;
|
|
422
|
-
for await (const content of response.body) {
|
|
423
|
-
signal.throwIfAborted();
|
|
424
|
-
await handle.appendFile(content);
|
|
425
|
-
progress('download', memberIndex, true);
|
|
426
|
-
}
|
|
427
|
-
signal.throwIfAborted();
|
|
428
|
-
} catch (error) {
|
|
429
|
-
downloadError = error;
|
|
430
|
-
failures.push(error);
|
|
431
|
-
firstFailure ??= error;
|
|
432
|
-
controller.abort(error);
|
|
433
|
-
}
|
|
434
|
-
if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
|
|
435
|
-
try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
|
|
436
|
-
}
|
|
437
|
-
if (handle) {
|
|
438
|
-
try { await handle.close(); } catch (error) { failures.push(error); }
|
|
439
|
-
}
|
|
440
|
-
if (failures.length) {
|
|
441
|
-
const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
|
|
442
|
-
firstFailure ??= error;
|
|
443
|
-
controller.abort(error);
|
|
444
|
-
throw error;
|
|
445
|
-
}
|
|
446
|
-
completeMember(memberIndex);
|
|
782
|
+
try { await downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure); }
|
|
783
|
+
catch (error) { noteFailure(error, 'failed'); throw error; }
|
|
447
784
|
};
|
|
448
785
|
}).filter(Boolean);
|
|
449
786
|
let archiveIndex = members.findIndex(function archiveMember(member) { return member.archiveMember !== undefined; });
|
|
450
787
|
for (const archive of entry.archives) {
|
|
451
788
|
const selected = new Map();
|
|
789
|
+
const archiveMembers = [];
|
|
452
790
|
for (const member of archive.members) {
|
|
791
|
+
const memberIndex = archiveIndex++;
|
|
792
|
+
archiveMembers.push(memberIndex);
|
|
453
793
|
const name = tarName(member.member);
|
|
454
794
|
if (!selected.has(name)) selected.set(name, []);
|
|
455
|
-
selected.get(name).push(
|
|
795
|
+
selected.get(name).push(memberIndex);
|
|
456
796
|
}
|
|
797
|
+
if (!selected.size) continue;
|
|
798
|
+
if (archiveMembers.every(function confirmed(index) { return finished.has(index) && completedArchiveMembers.has(index); })) continue;
|
|
799
|
+
for (const memberIndex of archiveMembers) completedArchiveMembers.delete(memberIndex);
|
|
457
800
|
jobs.push(async function downloadArchiveMembers() {
|
|
458
801
|
const found = new Set();
|
|
459
802
|
try {
|
|
803
|
+
state.completedArchiveMembers = [...completedArchiveMembers];
|
|
804
|
+
await save();
|
|
460
805
|
for (const range of archive.ranges ?? [undefined]) {
|
|
461
806
|
signal.throwIfAborted();
|
|
462
807
|
const response = await fetchModelResponse(archive.url, signal, range);
|
|
@@ -471,9 +816,13 @@ async function acquireNativeMembers(entry) {
|
|
|
471
816
|
error.members = missing;
|
|
472
817
|
throw error;
|
|
473
818
|
}
|
|
819
|
+
// A file written before a later TAR occurrence is retained
|
|
820
|
+
// on interruption, but only a complete pass confirms reuse.
|
|
821
|
+
for (const memberIndex of archiveMembers) completedArchiveMembers.add(memberIndex);
|
|
822
|
+
state.completedArchiveMembers = [...completedArchiveMembers];
|
|
823
|
+
await save();
|
|
474
824
|
} catch (error) {
|
|
475
|
-
|
|
476
|
-
controller.abort(error);
|
|
825
|
+
noteFailure(error, error.code?.startsWith('MODEL_ASSET_ARCHIVE_') ? 'failed' : 'interrupted');
|
|
477
826
|
throw error;
|
|
478
827
|
}
|
|
479
828
|
});
|
|
@@ -485,27 +834,41 @@ async function acquireNativeMembers(entry) {
|
|
|
485
834
|
await jobs[nextJob++]();
|
|
486
835
|
}
|
|
487
836
|
}));
|
|
488
|
-
const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
489
|
-
.map(function reason(result) { return result.reason; });
|
|
837
|
+
const failures = [...new Set(results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
838
|
+
.map(function reason(result) { return result.reason; }))];
|
|
490
839
|
if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
|
|
491
840
|
if (failures.length) throw failures[0];
|
|
492
841
|
signal.throwIfAborted();
|
|
493
842
|
progress('complete');
|
|
494
843
|
// The record describes a completed acquisition, not content identity.
|
|
495
844
|
// Publish it after every stream and file close, leaving older entries alone.
|
|
496
|
-
|
|
497
|
-
await writeFile(pending, JSON.stringify({members, order: ++nativeAcquisitionOrder}), {encoding: 'utf8', flag: 'wx', signal});
|
|
845
|
+
await save();
|
|
498
846
|
signal.throwIfAborted();
|
|
499
|
-
await
|
|
500
|
-
|
|
847
|
+
await saveAcquisitionState(entry.attempt, 'acquisition.json', {members, order: state.order, refresh: state.refresh});
|
|
848
|
+
clearInterval(heartbeat);
|
|
501
849
|
progress('ready');
|
|
850
|
+
await save();
|
|
502
851
|
return entry.location;
|
|
503
852
|
} catch (error) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
853
|
+
acquisitionError = error;
|
|
854
|
+
clearInterval(heartbeat);
|
|
855
|
+
progress(failureState ?? (signal.aborted ? 'cancelled' : 'failed'), undefined, false, {error: serializeCoreError(error)});
|
|
856
|
+
if (state) {
|
|
857
|
+
try { await save(); }
|
|
858
|
+
catch (stateError) {
|
|
859
|
+
acquisitionError = new AggregateError([error, stateError], 'Preparing model assets and retaining their interrupted state failed.');
|
|
860
|
+
throw acquisitionError;
|
|
861
|
+
}
|
|
507
862
|
}
|
|
508
863
|
throw error;
|
|
864
|
+
} finally {
|
|
865
|
+
clearInterval(heartbeat);
|
|
866
|
+
await stateTail;
|
|
867
|
+
try { await releaseOwner?.(); }
|
|
868
|
+
catch (error) {
|
|
869
|
+
if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Preparing model assets and releasing their acquisition owner failed.');
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
509
872
|
}
|
|
510
873
|
}
|
|
511
874
|
|
|
@@ -591,7 +954,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
591
954
|
return {path: member.path, nativePath: member.nativePath};
|
|
592
955
|
}),
|
|
593
956
|
preparationOwned: record.preparationOwned, uses: record.uses.size,
|
|
594
|
-
error: record.error ? serializeCoreError(record.error) : null
|
|
957
|
+
error: record.error ? serializeCoreError(record.error) : null,
|
|
958
|
+
...(record.progress ? {progress: record.progress} : {})
|
|
595
959
|
};
|
|
596
960
|
}
|
|
597
961
|
|
|
@@ -777,6 +1141,9 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
777
1141
|
record.directory = location.directory;
|
|
778
1142
|
record.members = location.members;
|
|
779
1143
|
}
|
|
1144
|
+
record.progress = value;
|
|
1145
|
+
record.state = value.state;
|
|
1146
|
+
publish(record);
|
|
780
1147
|
await onProgress?.(value);
|
|
781
1148
|
}});
|
|
782
1149
|
});
|