arcane-os 0.96.0 → 0.97.0
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 +14 -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 +64 -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 +448 -91
|
@@ -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
|
+
}
|
|
214
332
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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;
|
|
378
|
+
}
|
|
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;
|
|
@@ -336,7 +512,8 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
336
512
|
for (const memberIndex of memberIndexes) {
|
|
337
513
|
const location = entry.location.members[memberIndex];
|
|
338
514
|
await mkdir(path.dirname(location.nativePath), {recursive: true});
|
|
339
|
-
|
|
515
|
+
const partial = await entry.archivePart(memberIndex);
|
|
516
|
+
handles.push(await open(partial, 'wx'));
|
|
340
517
|
progress('download', memberIndex);
|
|
341
518
|
}
|
|
342
519
|
await consume(length, async function originalMemberContent(content) {
|
|
@@ -352,7 +529,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
352
529
|
if (errors.length) throw new AggregateError(errors, 'Extracting a model member and closing its files failed.');
|
|
353
530
|
for (const memberIndex of memberIndexes) {
|
|
354
531
|
found.add(memberIndex);
|
|
355
|
-
completeMember(memberIndex);
|
|
532
|
+
await completeMember(memberIndex);
|
|
356
533
|
}
|
|
357
534
|
} else await consume(length);
|
|
358
535
|
await consume((512 - length % 512) % 512);
|
|
@@ -365,95 +542,258 @@ async function extractTarRange(response, range, selected, found, entry, progress
|
|
|
365
542
|
if (operationError) throw operationError;
|
|
366
543
|
}
|
|
367
544
|
|
|
545
|
+
async function downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure) {
|
|
546
|
+
const member = entry.members[memberIndex];
|
|
547
|
+
const {signal} = entry.controller;
|
|
548
|
+
const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
|
|
549
|
+
let offset = 0;
|
|
550
|
+
try { offset = (await stat(partial)).size; }
|
|
551
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
552
|
+
const previous = state.transports[memberIndex];
|
|
553
|
+
let response;
|
|
554
|
+
let reader;
|
|
555
|
+
let handle;
|
|
556
|
+
let cancellation;
|
|
557
|
+
let ended = false;
|
|
558
|
+
let operationError;
|
|
559
|
+
let remaining;
|
|
560
|
+
|
|
561
|
+
async function restart(reason) {
|
|
562
|
+
progress('restart', memberIndex, false, {reason});
|
|
563
|
+
await retainRestartedPart(entry.attempt, memberIndex);
|
|
564
|
+
offset = 0;
|
|
565
|
+
}
|
|
566
|
+
async function request(headers) {
|
|
567
|
+
try { return await fetch(member.url, {signal, headers}); }
|
|
568
|
+
catch (error) { noteFailure(error, 'interrupted'); throw error; }
|
|
569
|
+
}
|
|
570
|
+
function cancelReader() {
|
|
571
|
+
if (!reader || ended) return;
|
|
572
|
+
cancellation ??= reader.cancel(signal.reason);
|
|
573
|
+
cancellation.catch(function observeReaderCancellation() {});
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
signal.throwIfAborted();
|
|
577
|
+
if (offset && previous?.encoding && previous.encoding !== 'identity') await restart('The previous response used encoded HTTP content and cannot be appended by Range.');
|
|
578
|
+
const validator = previous?.etag && !previous.etag.startsWith('W/') ? previous.etag : previous?.lastModified;
|
|
579
|
+
if (offset && !validator) await restart('The source supplied no HTTP validator for continuing its earlier response.');
|
|
580
|
+
const headers = {'Accept-Encoding': 'identity'};
|
|
581
|
+
if (offset) {
|
|
582
|
+
headers.Range = `bytes=${offset}-`;
|
|
583
|
+
headers['If-Range'] = validator;
|
|
584
|
+
progress('resume', memberIndex);
|
|
585
|
+
} else progress('download', memberIndex);
|
|
586
|
+
response = await request(headers);
|
|
587
|
+
signal.throwIfAborted();
|
|
588
|
+
if (offset && response.status === 416) {
|
|
589
|
+
await response.body?.cancel();
|
|
590
|
+
response = undefined;
|
|
591
|
+
await restart('The server no longer accepts this saved HTTP range.');
|
|
592
|
+
response = await request({'Accept-Encoding': 'identity'});
|
|
593
|
+
}
|
|
594
|
+
if (!response.ok) {
|
|
595
|
+
const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
|
|
596
|
+
error.url = member.url;
|
|
597
|
+
error.status = response.status;
|
|
598
|
+
error.response = await response.text();
|
|
599
|
+
noteFailure(error, response.status >= 500 || response.status === 408 || response.status === 429 ? 'interrupted' : 'failed');
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
|
|
603
|
+
if (offset && response.status === 200) await restart('The server returned the complete file instead of the saved HTTP range.');
|
|
604
|
+
if (response.status === 206) {
|
|
605
|
+
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
|
|
606
|
+
const encoding = response.headers.get('content-encoding');
|
|
607
|
+
const validatorHeader = previous?.etag && !previous.etag.startsWith('W/') ? 'etag' : 'last-modified';
|
|
608
|
+
const currentValidator = response.headers.get(validatorHeader);
|
|
609
|
+
const changed = currentValidator && currentValidator !== validator;
|
|
610
|
+
if (!offset || !range || Number(range[1]) !== offset || Number(range[2]) < offset
|
|
611
|
+
|| Number(range[2]) + 1 !== Number(range[3]) || (encoding && encoding !== 'identity') || changed) {
|
|
612
|
+
const error = failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server did not return the requested continuation of this model file.');
|
|
613
|
+
error.url = member.url;
|
|
614
|
+
error.status = response.status;
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
// Content-Range framing is private to this HTTP transfer. It never
|
|
618
|
+
// participates in model identity, admission or user-facing progress.
|
|
619
|
+
remaining = Number(range[2]) - Number(range[1]) + 1;
|
|
620
|
+
}
|
|
621
|
+
state.transports[memberIndex] = {
|
|
622
|
+
etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'),
|
|
623
|
+
encoding: response.headers.get('content-encoding')
|
|
624
|
+
};
|
|
625
|
+
await save();
|
|
626
|
+
handle = await open(partial, 'a');
|
|
627
|
+
reader = response.body.getReader();
|
|
628
|
+
signal.addEventListener('abort', cancelReader, {once: true});
|
|
629
|
+
if (signal.aborted) cancelReader();
|
|
630
|
+
while (true) {
|
|
631
|
+
signal.throwIfAborted();
|
|
632
|
+
let chunk;
|
|
633
|
+
try { chunk = await reader.read(); }
|
|
634
|
+
catch (error) { noteFailure(error, 'interrupted'); throw error; }
|
|
635
|
+
signal.throwIfAborted();
|
|
636
|
+
if (chunk.done) { ended = true; break; }
|
|
637
|
+
if (remaining !== undefined) {
|
|
638
|
+
remaining -= chunk.value.byteLength;
|
|
639
|
+
if (remaining < 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server exceeded its HTTP Content-Range.');
|
|
640
|
+
}
|
|
641
|
+
await handle.appendFile(chunk.value);
|
|
642
|
+
progress('download', memberIndex, true);
|
|
643
|
+
}
|
|
644
|
+
if (remaining !== undefined && remaining !== 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server ended before its HTTP Content-Range.');
|
|
645
|
+
signal.throwIfAborted();
|
|
646
|
+
} catch (error) { operationError = error; noteFailure(error, 'failed'); }
|
|
647
|
+
signal.removeEventListener('abort', cancelReader);
|
|
648
|
+
try {
|
|
649
|
+
if (reader && !ended) { cancellation ??= reader.cancel(operationError); await cancellation; }
|
|
650
|
+
else if (!reader && response?.body && !response.bodyUsed && !response.body.locked) await response.body.cancel(operationError);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (error !== operationError) operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its response failed.') : error;
|
|
653
|
+
}
|
|
654
|
+
try { reader?.releaseLock(); }
|
|
655
|
+
catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and releasing its reader failed.') : error; }
|
|
656
|
+
try { await handle?.close(); }
|
|
657
|
+
catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its file failed.') : error; }
|
|
658
|
+
if (operationError) { noteFailure(operationError, 'failed'); throw operationError; }
|
|
659
|
+
await completeMember(memberIndex);
|
|
660
|
+
}
|
|
661
|
+
|
|
368
662
|
async function acquireNativeMembers(entry) {
|
|
369
|
-
const {
|
|
663
|
+
const {members, controller} = entry;
|
|
370
664
|
const {signal} = controller;
|
|
371
|
-
let attempt;
|
|
372
665
|
let completed = 0;
|
|
373
|
-
let committed = false;
|
|
374
666
|
let firstFailure;
|
|
667
|
+
let failureState;
|
|
668
|
+
let releaseOwner;
|
|
669
|
+
let state;
|
|
670
|
+
let stateTail = Promise.resolve();
|
|
671
|
+
let heartbeat;
|
|
672
|
+
let acquisitionError;
|
|
673
|
+
let lastActivity = Date.now();
|
|
674
|
+
entry.activity = function receivedTransportContent() { lastActivity = Date.now(); };
|
|
375
675
|
const activity = new Map();
|
|
376
|
-
function
|
|
676
|
+
function noteFailure(error, selectedState) {
|
|
677
|
+
if (firstFailure || signal.aborted) return;
|
|
678
|
+
firstFailure = error;
|
|
679
|
+
failureState = selectedState;
|
|
680
|
+
controller.abort(error);
|
|
681
|
+
}
|
|
682
|
+
function save() {
|
|
683
|
+
const next = stateTail.then(function saveTransferState() { return saveAcquisitionState(entry.attempt, 'transfer.json', state); });
|
|
684
|
+
stateTail = next.catch(function observeStateFailure(error) { noteFailure(error, 'failed'); });
|
|
685
|
+
return next;
|
|
686
|
+
}
|
|
687
|
+
function progress(phase, memberIndex, repeatedActivity = false, details = {}) {
|
|
688
|
+
if (phase === 'download') lastActivity = Date.now();
|
|
377
689
|
if (repeatedActivity) {
|
|
378
690
|
const now = Date.now();
|
|
379
691
|
if (now - (activity.get(memberIndex) ?? 0) < 1000) return;
|
|
380
692
|
activity.set(memberIndex, now);
|
|
381
693
|
}
|
|
382
|
-
|
|
383
|
-
|
|
694
|
+
const selectedState = details.state ?? ({waiting: 'waiting', stalled: 'stalled', interrupted: 'interrupted',
|
|
695
|
+
cancelled: 'cancelled', failed: 'failed', ready: 'ready'})[phase] ?? 'downloading';
|
|
696
|
+
const messages = {
|
|
697
|
+
waiting: 'Another application is preparing these model files. Waiting for its acquisition.',
|
|
698
|
+
downloading: 'Downloading model files.', stalled: 'The model download is still open; no recent data has arrived.',
|
|
699
|
+
interrupted: 'The model download was interrupted. Completed files and resumable data were kept.',
|
|
700
|
+
cancelled: 'The model download was cancelled. Completed files and resumable data were kept.',
|
|
701
|
+
failed: 'The model download failed. Existing files and resumable data were kept.', ready: 'Model files are ready.'
|
|
702
|
+
};
|
|
703
|
+
const notification = {phase, state: selectedState, message: phase === 'resume' ? 'Resuming the saved model download.'
|
|
704
|
+
: phase === 'restart' ? 'Restarting this model file because its saved HTTP continuation is unavailable. Earlier partial data was retained.'
|
|
705
|
+
: phase === 'reuse' ? 'Reusing downloaded model files.' : messages[selectedState],
|
|
706
|
+
completed, total: members.length, unit: 'files',
|
|
707
|
+
...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path}), ...details};
|
|
708
|
+
if (['interrupted', 'cancelled', 'failed'].includes(selectedState)) notification.recovery = 'Retry to reuse completed files and continue saved downloads.';
|
|
709
|
+
if (selectedState === 'stalled') notification.recovery = 'Wait for the source to respond, or cancel and retry later.';
|
|
710
|
+
if (state) state.progress = notification;
|
|
711
|
+
entry.publish(notification);
|
|
384
712
|
}
|
|
385
713
|
try {
|
|
386
714
|
signal.throwIfAborted();
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
715
|
+
const selected = await selectNativeAcquisition(entry, progress);
|
|
716
|
+
entry.attempt = selected.root;
|
|
717
|
+
entry.location = {directory: selected.directory, members: selected.members};
|
|
718
|
+
if (selected.complete) {
|
|
390
719
|
completed = members.length;
|
|
391
720
|
progress('reuse');
|
|
392
721
|
progress('ready');
|
|
393
|
-
return
|
|
722
|
+
return entry.location;
|
|
394
723
|
}
|
|
395
724
|
signal.throwIfAborted();
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
725
|
+
releaseOwner = await acquireDirectoryOwner(selected.root, '.transfer-owner', signal, async function waitingForTransfer() {
|
|
726
|
+
try {
|
|
727
|
+
const observed = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8'));
|
|
728
|
+
completed = observed.completedMembers?.length ?? 0;
|
|
729
|
+
progress('waiting', undefined, false, {activity: observed.progress});
|
|
730
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; progress('waiting'); }
|
|
731
|
+
});
|
|
732
|
+
const current = (await storedAcquisitions(entry.directory, members, signal)).find(function ownedAttempt(candidate) { return candidate.root === selected.root; });
|
|
733
|
+
if (current?.complete) {
|
|
734
|
+
completed = members.length;
|
|
735
|
+
progress('reuse');
|
|
736
|
+
progress('ready');
|
|
737
|
+
return entry.location;
|
|
738
|
+
}
|
|
739
|
+
try { state = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8')); }
|
|
740
|
+
catch (error) {
|
|
741
|
+
if (error.code !== 'ENOENT') throw error;
|
|
742
|
+
state = {members, order: selected.order, refresh: selected.refresh, transports: {}};
|
|
743
|
+
}
|
|
744
|
+
state.transports ??= {};
|
|
745
|
+
state.completedMembers = current?.completedMembers ?? [];
|
|
746
|
+
const finished = new Set(state.completedMembers);
|
|
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();
|
|
452
789
|
for (const member of archive.members) {
|
|
790
|
+
const memberIndex = archiveIndex++;
|
|
791
|
+
if (finished.has(memberIndex)) continue;
|
|
453
792
|
const name = tarName(member.member);
|
|
454
793
|
if (!selected.has(name)) selected.set(name, []);
|
|
455
|
-
selected.get(name).push(
|
|
794
|
+
selected.get(name).push(memberIndex);
|
|
456
795
|
}
|
|
796
|
+
if (!selected.size) continue;
|
|
457
797
|
jobs.push(async function downloadArchiveMembers() {
|
|
458
798
|
const found = new Set();
|
|
459
799
|
try {
|
|
@@ -472,8 +812,7 @@ async function acquireNativeMembers(entry) {
|
|
|
472
812
|
throw error;
|
|
473
813
|
}
|
|
474
814
|
} catch (error) {
|
|
475
|
-
|
|
476
|
-
controller.abort(error);
|
|
815
|
+
noteFailure(error, error.code?.startsWith('MODEL_ASSET_ARCHIVE_') ? 'failed' : 'interrupted');
|
|
477
816
|
throw error;
|
|
478
817
|
}
|
|
479
818
|
});
|
|
@@ -485,27 +824,41 @@ async function acquireNativeMembers(entry) {
|
|
|
485
824
|
await jobs[nextJob++]();
|
|
486
825
|
}
|
|
487
826
|
}));
|
|
488
|
-
const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
489
|
-
.map(function reason(result) { return result.reason; });
|
|
827
|
+
const failures = [...new Set(results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
828
|
+
.map(function reason(result) { return result.reason; }))];
|
|
490
829
|
if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
|
|
491
830
|
if (failures.length) throw failures[0];
|
|
492
831
|
signal.throwIfAborted();
|
|
493
832
|
progress('complete');
|
|
494
833
|
// The record describes a completed acquisition, not content identity.
|
|
495
834
|
// 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});
|
|
835
|
+
await save();
|
|
498
836
|
signal.throwIfAborted();
|
|
499
|
-
await
|
|
500
|
-
|
|
837
|
+
await saveAcquisitionState(entry.attempt, 'acquisition.json', {members, order: state.order, refresh: state.refresh});
|
|
838
|
+
clearInterval(heartbeat);
|
|
501
839
|
progress('ready');
|
|
840
|
+
await save();
|
|
502
841
|
return entry.location;
|
|
503
842
|
} catch (error) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
843
|
+
acquisitionError = error;
|
|
844
|
+
clearInterval(heartbeat);
|
|
845
|
+
progress(failureState ?? (signal.aborted ? 'cancelled' : 'failed'), undefined, false, {error: serializeCoreError(error)});
|
|
846
|
+
if (state) {
|
|
847
|
+
try { await save(); }
|
|
848
|
+
catch (stateError) {
|
|
849
|
+
acquisitionError = new AggregateError([error, stateError], 'Preparing model assets and retaining their interrupted state failed.');
|
|
850
|
+
throw acquisitionError;
|
|
851
|
+
}
|
|
507
852
|
}
|
|
508
853
|
throw error;
|
|
854
|
+
} finally {
|
|
855
|
+
clearInterval(heartbeat);
|
|
856
|
+
await stateTail;
|
|
857
|
+
try { await releaseOwner?.(); }
|
|
858
|
+
catch (error) {
|
|
859
|
+
if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Preparing model assets and releasing their acquisition owner failed.');
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
509
862
|
}
|
|
510
863
|
}
|
|
511
864
|
|
|
@@ -591,7 +944,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
591
944
|
return {path: member.path, nativePath: member.nativePath};
|
|
592
945
|
}),
|
|
593
946
|
preparationOwned: record.preparationOwned, uses: record.uses.size,
|
|
594
|
-
error: record.error ? serializeCoreError(record.error) : null
|
|
947
|
+
error: record.error ? serializeCoreError(record.error) : null,
|
|
948
|
+
...(record.progress ? {progress: record.progress} : {})
|
|
595
949
|
};
|
|
596
950
|
}
|
|
597
951
|
|
|
@@ -777,6 +1131,9 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
777
1131
|
record.directory = location.directory;
|
|
778
1132
|
record.members = location.members;
|
|
779
1133
|
}
|
|
1134
|
+
record.progress = value;
|
|
1135
|
+
record.state = value.state;
|
|
1136
|
+
publish(record);
|
|
780
1137
|
await onProgress?.(value);
|
|
781
1138
|
}});
|
|
782
1139
|
});
|