arcane-os 0.89.0 → 0.91.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.
@@ -1,16 +1,231 @@
1
- import {mkdir, mkdtemp, open, rm} from 'node:fs/promises';
1
+ import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
2
2
  import {Buffer} from 'node:buffer';
3
3
  import path from 'node:path';
4
4
  import Is from 'strong-type';
5
5
  import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
6
6
 
7
7
  const is = new Is(false);
8
+ const nativeAcquisitions = new Map();
9
+ let nativeAcquisitionOrder = 0;
8
10
 
9
11
  function failure(code, message) {
10
12
  return new CoreError({code, message});
11
13
  }
12
14
 
13
- /** Working files for native engines. Caller-selected source assets stay authoritative. */
15
+ function memberLocation(directory, member) {
16
+ const nativePath = path.resolve(directory, member.path);
17
+ const relative = path.relative(directory, nativePath);
18
+ if (path.isAbsolute(member.path) || !relative || relative === '..'
19
+ || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
20
+ throw new TypeError('Model asset member paths must be relative files within their projection.');
21
+ }
22
+ return {path: member.path, nativePath};
23
+ }
24
+
25
+ function sameMembers(left, right) {
26
+ return is.array(left) && left.length === right.length
27
+ && left.every(function sameMember(member, index) {
28
+ return member.path === right[index].path && member.url === right[index].url;
29
+ });
30
+ }
31
+
32
+ async function storedAcquisition(directory, members, signal) {
33
+ let latest;
34
+ for (const entry of await readdir(directory, {withFileTypes: true})) {
35
+ signal.throwIfAborted();
36
+ if (!entry.isDirectory() || !entry.name.startsWith('acquisition-')) continue;
37
+ const root = path.join(directory, entry.name);
38
+ let selected;
39
+ try { selected = JSON.parse(await readFile(path.join(root, 'acquisition.json'), {encoding: 'utf8', signal})); }
40
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
41
+ if (!sameMembers(selected.members, members)) continue;
42
+ nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order);
43
+ const files = path.join(root, 'files');
44
+ const locations = members.map(function originalLocation(member) { return memberLocation(files, member); });
45
+ let available = true;
46
+ for (const member of locations) {
47
+ signal.throwIfAborted();
48
+ try { if (!(await stat(member.nativePath)).isFile()) available = false; }
49
+ catch (error) { if (error.code !== 'ENOENT') throw error; available = false; }
50
+ }
51
+ if (available && (!latest || selected.order > latest.order
52
+ || (selected.order === latest.order && entry.name > latest.name))) {
53
+ latest = {name: entry.name, order: selected.order, directory: files, members: locations};
54
+ }
55
+ }
56
+ return latest ? {directory: latest.directory, members: latest.members} : null;
57
+ }
58
+
59
+ async function acquireNativeMembers(entry) {
60
+ const {directory, members, controller} = entry;
61
+ const {signal} = controller;
62
+ let attempt;
63
+ let completed = 0;
64
+ let committed = false;
65
+ let firstFailure;
66
+ function progress(phase, memberIndex) {
67
+ entry.publish({phase, completed, total: members.length, unit: 'files',
68
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})});
69
+ }
70
+ try {
71
+ signal.throwIfAborted();
72
+ const stored = await storedAcquisition(directory, members, signal);
73
+ if (stored && !entry.refresh) {
74
+ entry.location = stored;
75
+ completed = members.length;
76
+ progress('reuse');
77
+ progress('ready');
78
+ return stored;
79
+ }
80
+ signal.throwIfAborted();
81
+ attempt = await mkdtemp(path.join(directory, 'acquisition-'));
82
+ const files = path.join(attempt, 'files');
83
+ await mkdir(files);
84
+ entry.location = {directory: files, members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
85
+ progress('open');
86
+ const results = await Promise.allSettled(members.map(async function downloadMember(member, memberIndex) {
87
+ let handle;
88
+ let response;
89
+ let downloadError;
90
+ const failures = [];
91
+ let bodyOwned = false;
92
+ try {
93
+ signal.throwIfAborted();
94
+ const nativePath = entry.location.members[memberIndex].nativePath;
95
+ await mkdir(path.dirname(nativePath), {recursive: true});
96
+ handle = await open(nativePath, 'wx');
97
+ signal.throwIfAborted();
98
+ progress('download', memberIndex);
99
+ response = await fetch(member.url, {signal});
100
+ if (!response.ok) {
101
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
102
+ error.url = member.url;
103
+ error.status = response.status;
104
+ error.response = await response.text();
105
+ throw error;
106
+ }
107
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
108
+ bodyOwned = true;
109
+ for await (const content of response.body) {
110
+ signal.throwIfAborted();
111
+ await handle.appendFile(content);
112
+ progress('download', memberIndex);
113
+ }
114
+ signal.throwIfAborted();
115
+ } catch (error) {
116
+ downloadError = error;
117
+ failures.push(error);
118
+ firstFailure ??= error;
119
+ controller.abort(error);
120
+ }
121
+ if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
122
+ try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
123
+ }
124
+ if (handle) {
125
+ try { await handle.close(); } catch (error) { failures.push(error); }
126
+ }
127
+ if (failures.length) {
128
+ const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
129
+ firstFailure ??= error;
130
+ controller.abort(error);
131
+ throw error;
132
+ }
133
+ completed += 1;
134
+ progress('download', memberIndex);
135
+ }));
136
+ const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
137
+ .map(function reason(result) { return result.reason; });
138
+ if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
139
+ if (failures.length) throw failures[0];
140
+ signal.throwIfAborted();
141
+ progress('complete');
142
+ // The record describes a completed acquisition, not content identity.
143
+ // Publish it after every stream and file close, leaving older entries alone.
144
+ const pending = path.join(attempt, 'acquisition.pending.json');
145
+ await writeFile(pending, JSON.stringify({members, order: ++nativeAcquisitionOrder}), {encoding: 'utf8', flag: 'wx', signal});
146
+ signal.throwIfAborted();
147
+ await rename(pending, path.join(attempt, 'acquisition.json'));
148
+ committed = true;
149
+ progress('ready');
150
+ return entry.location;
151
+ } catch (error) {
152
+ if (attempt && !committed) {
153
+ try { await rm(attempt, {recursive: true}); }
154
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Preparing native model assets failed and cleanup failed.'); }
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ function shareNativeAcquisition({directory, members, refresh, signal, onProgress}) {
161
+ signal.throwIfAborted();
162
+ const key = JSON.stringify([directory, members, refresh]);
163
+ let entry = nativeAcquisitions.get(key);
164
+ if (entry?.controller.signal.aborted) {
165
+ return entry.task.catch(function observeRetiringAcquisition() {}).then(function acquireAfterDrain() {
166
+ return shareNativeAcquisition({directory, members, refresh, signal, onProgress});
167
+ });
168
+ }
169
+ if (!entry) {
170
+ entry = {directory, members, refresh, controller: new AbortController(), consumers: new Set(), task: null, location: null, progress: null};
171
+ nativeAcquisitions.set(key, entry);
172
+ entry.publish = function publishProgress(progress) {
173
+ entry.progress = progress;
174
+ for (const consumer of entry.consumers) consumer.progress(progress);
175
+ };
176
+ }
177
+ const acquisition = entry;
178
+ return new Promise(function observeAcquisition(resolve, reject) {
179
+ let settled = false;
180
+ let cancelled;
181
+ let observer = Promise.resolve();
182
+ const consumer = {progress};
183
+ function finish(failed, error, location) {
184
+ if (settled) return;
185
+ settled = true;
186
+ signal.removeEventListener('abort', abort);
187
+ acquisition.consumers.delete(consumer);
188
+ if (consumer.cancelled && failed && error !== cancelled) {
189
+ reject(new AggregateError([cancelled, error], 'Native model acquisition cancellation and cleanup failed.', {cause: cancelled}));
190
+ } else if (consumer.cancelled) reject(cancelled);
191
+ else if (failed) reject(error);
192
+ else resolve(location);
193
+ }
194
+ function cancel(error) {
195
+ if (settled || consumer.cancelled) return;
196
+ cancelled = error;
197
+ consumer.cancelled = true;
198
+ const others = [...acquisition.consumers].some(function interested(other) { return other !== consumer && !other.cancelled; });
199
+ if (others) observer.then(function cancelledObserverSettled() { finish(true, error); });
200
+ else acquisition.controller.abort(error);
201
+ }
202
+ function abort() { cancel(signal.reason); }
203
+ function progress(value) {
204
+ const notification = {...value};
205
+ // Each subscriber owns its ordered observers. A slow or failing
206
+ // observer never holds another subscriber's transfer or result.
207
+ observer = observer.then(async function observeProgress() {
208
+ if (!consumer.cancelled && !settled) await onProgress?.(notification, acquisition.location);
209
+ }).catch(cancel);
210
+ }
211
+ acquisition.consumers.add(consumer);
212
+ signal.addEventListener('abort', abort, {once: true});
213
+ if (acquisition.progress) progress(acquisition.progress);
214
+ if (signal.aborted) abort();
215
+ if (!acquisition.task) {
216
+ acquisition.task = Promise.resolve().then(function acquireSelectedMembers() { return acquireNativeMembers(acquisition); });
217
+ function releaseAcquisition() { if (nativeAcquisitions.get(key) === acquisition) nativeAcquisitions.delete(key); }
218
+ acquisition.task.then(releaseAcquisition, releaseAcquisition);
219
+ }
220
+ acquisition.task.then(function completed(location) {
221
+ observer.then(function observersCompleted() { finish(false, null, location); });
222
+ }, function failed(error) {
223
+ observer.then(function observersCompleted() { finish(true, error); });
224
+ });
225
+ });
226
+ }
227
+
228
+ /** Temporary browser projections and persistent selected native acquisitions. */
14
229
  export function createModelAssetService({appRoot = process.cwd()} = {}) {
15
230
  const projections = new Map();
16
231
  let context;
@@ -66,7 +281,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
66
281
  try { await record.downloading; } catch (error) { record.error ??= error; }
67
282
  try { await record.finishing; } catch (error) { record.error ??= error; }
68
283
  try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
69
- if (record.directory && filesClosed) {
284
+ if (record.directory && filesClosed && !record.persistent) {
70
285
  try { await rm(record.directory, {recursive: true}); }
71
286
  catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
72
287
  }
@@ -107,8 +322,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
107
322
  throw error;
108
323
  }
109
324
 
110
- async function openProjection({id, workingDirectory, members}, request) {
111
- request.signal.throwIfAborted();
325
+ function beginProjection({id, workingDirectory, members}, signal) {
326
+ signal.throwIfAborted();
112
327
  if (closing) throw failure('MODEL_ASSETS_CLOSING', 'Model asset preparation is closing.');
113
328
  if (!is.string(id) || !id) throw new TypeError('A model asset projection needs an operation id.');
114
329
  if (!is.string(workingDirectory) || !workingDirectory) {
@@ -126,21 +341,20 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
126
341
  });
127
342
  record.released.catch(function observeReleaseFailure() {});
128
343
  projections.set(id, record);
344
+ return record;
345
+ }
346
+
347
+ async function openProjection({id, workingDirectory, members}, request) {
348
+ const record = beginProjection({id, workingDirectory, members}, request.signal);
129
349
  record.opening = Promise.resolve().then(async function createWorkingFiles() {
130
350
  const selectedDirectory = path.resolve(appRoot, workingDirectory);
131
351
  await mkdir(selectedDirectory, {recursive: true});
132
352
  request.signal.throwIfAborted();
133
353
  record.directory = await mkdtemp(path.join(selectedDirectory, 'arcane-model-'));
134
354
  record.members = members.map(function memberRecord(member) {
135
- const nativePath = path.resolve(record.directory, member.path);
136
- const relative = path.relative(record.directory, nativePath);
137
355
  // A projection consists of files under its one owned directory.
138
356
  // Absolute or escaping members cannot participate in that lifetime.
139
- if (path.isAbsolute(member.path) || !relative || relative === '..'
140
- || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
141
- throw new TypeError('Model asset member paths must be relative files within their projection.');
142
- }
143
- return {path: member.path, nativePath, handle: null, tail: Promise.resolve()};
357
+ return {...memberLocation(record.directory, member), handle: null, tail: Promise.resolve()};
144
358
  });
145
359
  const results = await Promise.allSettled(record.members.map(async function createMember(member) {
146
360
  request.signal.throwIfAborted();
@@ -189,78 +403,44 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
189
403
  return task;
190
404
  }
191
405
 
192
- async function prepare({id, workingDirectory, members, signal, onProgress} = {}) {
406
+ async function prepare({id, workingDirectory, members, signal, onProgress, refresh = false} = {}) {
193
407
  const controller = new AbortController();
194
408
  const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
195
- const request = {signal: operationSignal};
196
- let record;
197
- let completed = 0;
198
- let firstFailure;
199
-
200
- async function progress(phase, memberIndex) {
409
+ const record = beginProjection({id, workingDirectory, members}, operationSignal);
410
+ let selectedMembers;
411
+ try { selectedMembers = members.map(function selectedMember(member) { return {path: member.path, url: member.url}; }); }
412
+ catch (error) { return failedOperation(record, error); }
413
+ record.persistent = true;
414
+ record.downloadController = controller;
415
+ // This task owns only acquisition. Failure cleanup may join it without
416
+ // recursively waiting for the preparation promise that requests cleanup.
417
+ record.downloading = Promise.resolve().then(async function prepareNativeFiles() {
201
418
  operationSignal.throwIfAborted();
202
- await onProgress?.({
203
- phase, completed, total: members.length, unit: 'files',
204
- ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})
205
- });
419
+ const selectedDirectory = path.join(path.resolve(appRoot, workingDirectory), 'model-assets');
420
+ await mkdir(selectedDirectory, {recursive: true});
421
+ const directory = await realpath(selectedDirectory);
206
422
  operationSignal.throwIfAborted();
207
- }
208
-
209
- async function downloadMember(member, memberIndex) {
210
- try {
211
- await progress('download', memberIndex);
212
- const response = await fetch(member.url, {signal: operationSignal});
213
- if (!response.ok) {
214
- const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
215
- error.url = member.url;
216
- error.status = response.status;
217
- error.response = await response.text();
218
- throw error;
219
- }
220
- if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
221
- for await (const content of response.body) {
222
- operationSignal.throwIfAborted();
223
- await appendMemberContent(record, record.members[memberIndex], content, operationSignal);
224
- await progress('download', memberIndex);
225
- }
226
- completed += 1;
227
- await progress('download', memberIndex);
228
- } catch (error) {
229
- firstFailure ??= error;
230
- controller.abort(error);
231
- throw error;
232
- }
233
- }
234
-
423
+ return shareNativeAcquisition({directory, members: selectedMembers, refresh: refresh === true, signal: operationSignal,
424
+ async onProgress(value, location) {
425
+ if (location) {
426
+ record.directory = location.directory;
427
+ record.members = location.members;
428
+ }
429
+ await onProgress?.(value);
430
+ }});
431
+ });
432
+ publish(record);
235
433
  try {
236
- await openProjection({id, workingDirectory, members}, request);
237
- record = requireProjection(id);
434
+ const location = await record.downloading;
238
435
  operationSignal.throwIfAborted();
239
- if (!record.preparationOwned || closing) {
240
- throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
241
- }
242
- record.downloadController = controller;
243
- // Cleanup joins this transfer-only task. Its rejection handler below
244
- // may release preparation without waiting on itself.
245
- record.downloading = Promise.resolve().then(async function downloadMembers() {
246
- await progress('open');
247
- const results = await Promise.allSettled(members.map(downloadMember));
248
- const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
249
- .map(function reason(result) { return result.reason; });
250
- if (errors.length > 1) {
251
- throw new AggregateError(errors, 'Downloading model asset members failed.', {cause: firstFailure});
252
- }
253
- if (errors.length) throw errors[0];
254
- });
255
- await record.downloading;
256
- await progress('complete');
257
- const projection = await completeProjection({id}, request);
258
- await progress('ready');
259
- return projection;
436
+ record.directory = location.directory;
437
+ record.members = location.members;
438
+ record.state = 'ready';
439
+ publish(record);
440
+ return snapshot(record);
260
441
  } catch (error) {
261
442
  controller.abort(error);
262
- if (record) return failedOperation(record, error);
263
- throw error;
443
+ return failedOperation(record, error);
264
444
  }
265
445
  }
266
446
 
@@ -5,6 +5,7 @@ import {runProcess} from './process.mjs';
5
5
  import {ArcaneError, ERROR_CODES, throwIfAborted} from './errors.mjs';
6
6
  import {createEventQueue} from './event-queue.mjs';
7
7
  import {createGitIdentityRunner} from './git-identity.mjs';
8
+ import {captureRepositoryRemote} from './repository.mjs';
8
9
 
9
10
  const is = new Is(false);
10
11
  const snapshotRef = 'refs/arcane/text-snapshot';
@@ -14,11 +15,12 @@ const snapshotRef = 'refs/arcane/text-snapshot';
14
15
  * One application service owns each dedicated cache directory. Construction
15
16
  * performs no I/O; refresh coalesces callers and close drains accepted work.
16
17
  */
17
- export function createGitTextSnapshot({cacheDirectory, remote, ref, selectPath, gitIdentity, onEvent, run = runProcess} = {}) {
18
+ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref, selectPath, gitIdentity, onEvent, run = runProcess} = {}) {
18
19
  for (const [name, value] of Object.entries({cacheDirectory, remote, ref})) {
19
20
  if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
20
21
  }
21
22
  if (!is.function(selectPath)) throw new TypeError('selectPath must be a function.');
23
+ const remoteArgument = captureRepositoryRemote(remote, remoteBase);
22
24
  const execute = createGitIdentityRunner(run, gitIdentity);
23
25
  const directory = path.resolve(cacheDirectory);
24
26
  let prepared = false;
@@ -68,7 +70,7 @@ export function createGitTextSnapshot({cacheDirectory, remote, ref, selectPath,
68
70
  try {
69
71
  await events.send({type: 'git.snapshot.refreshing', message: 'Refreshing the selected repository revision.', data: {ref}});
70
72
  await prepare(events);
71
- await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remote, `+${ref}:${snapshotRef}`], {onEvent: events.send});
73
+ await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remoteArgument, `+${ref}:${snapshotRef}`], {onEvent: events.send});
72
74
  const selected = await git(['rev-parse', '--verify', `${snapshotRef}^{commit}`], {onEvent: events.send});
73
75
  const revision = selected.stdout.trim();
74
76
  if (retained?.revision === revision) {
@@ -13,6 +13,7 @@ import {
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
15
  import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
+ import {materializeBrowserDecisions} from '../browser-decisions-distribution.mjs';
16
17
  import {TARGET_IDS} from '../constants.mjs';
17
18
  import {normalizeLocalAIConfig} from '../local-ai/config.mjs';
18
19
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
@@ -904,7 +905,8 @@ async function replaceDirectory(stagingRoot,outputRoot){
904
905
  }
905
906
 
906
907
  async function packageWithContext(context,options={}){
907
- const {signal,onEvent,moduleFormat='import-map',browserPwa:requestedBrowserPwa=true}=options;
908
+ const {signal,onEvent,moduleFormat='import-map',browserPwa:requestedBrowserPwa=true,browserDecisions=false}=options;
909
+ if (!is.boolean(browserDecisions)) throw new TypeError('packageApp browserDecisions must be a boolean.');
908
910
  const browserPwa=context.target==='browser'&&requestedBrowserPwa;
909
911
  if(!['import-map','native'].includes(moduleFormat)){
910
912
  throw new TypeError('packageApp moduleFormat must be "import-map" or "native".');
@@ -1151,7 +1153,32 @@ async function packageWithContext(context,options={}){
1151
1153
  if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
1152
1154
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
1153
1155
  }
1154
- const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
1156
+ const installed=pwaEnabled||browserDecisions
1157
+ ?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
1158
+ if (browserDecisions) {
1159
+ const moduleSource = installed
1160
+ ? path.join(installed.packageRoot, 'browser-runtime', 'ai', 'browser-decisions.mjs')
1161
+ : path.join(context.workspaceRoot, 'arcane', 'sdk', 'ai', 'browser-decisions.mjs');
1162
+ const moduleRecord = browserRecords.find(
1163
+ function selectedDecisionModule(record) { return record.source === moduleSource; }
1164
+ );
1165
+ if (!moduleRecord || !inventory.has(moduleRecord.destination)) {
1166
+ fail('packageApp browserDecisions requires the selected SDK browser-decisions module in the package.');
1167
+ }
1168
+ const distributionPath = path.posix.join(path.posix.dirname(moduleRecord.destination), 'decisions-runtime');
1169
+ // Acquire after native module and asset rewriting. The upstream
1170
+ // distribution stays opaque and joins the final PWA/release inventory.
1171
+ const distribution = await materializeBrowserDecisions(
1172
+ path.join(stagingRoot, ...distributionPath.split('/')),
1173
+ signal,
1174
+ onEvent
1175
+ );
1176
+ for (const file of distribution.files) {
1177
+ const relative = `${distributionPath}/${file}`;
1178
+ if (!inventory.has(relative)) files.push(relative);
1179
+ inventory.add(relative);
1180
+ }
1181
+ }
1155
1182
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
1156
1183
  app:{
1157
1184
  id:context.appId,
@@ -56,20 +56,33 @@ export async function readGitText(args, {
56
56
  }
57
57
  }
58
58
 
59
- /** Capture a caller-selected operation target without changing its strings. */
59
+ /** Derive a Git argument only when the caller explicitly selects a local base. */
60
+ export function captureRepositoryRemote(remote, remoteBase) {
61
+ if (remoteBase === undefined) return remote;
62
+ if (!is.string(remoteBase) || !remoteBase.isWellFormed() || remoteBase.includes('\0')) {
63
+ throw new TypeError('remoteBase must be text representable as a native filesystem path.');
64
+ }
65
+ return remote === undefined ? undefined : path.resolve(remoteBase, remote);
66
+ }
67
+
68
+ /** Keep original selection text separate from the accepted command argument. */
60
69
  export function captureRepositoryTarget(target) {
61
70
  if (target === undefined) return undefined;
62
- const {remote, ref} = target;
71
+ const {remote, ref, remoteBase} = target;
63
72
  for (const [name, value] of Object.entries({remote, ref})) {
64
73
  if (!is.string(value) || value === '' || !value.isWellFormed() || value.includes('\0')) {
65
74
  throw new TypeError(`target.${name} must be nonempty text representable as a native process argument.`);
66
75
  }
67
76
  }
68
- return {remote, ref};
77
+ return {
78
+ target: {remote, ref, ...(remoteBase === undefined ? {} : {remoteBase})},
79
+ remoteArgument: captureRepositoryRemote(remote, remoteBase)
80
+ };
69
81
  }
70
82
 
71
- export function repositoryPushArguments(target) {
72
- return target === undefined ? ['push'] : ['push', '--no-follow-tags', '--', target.remote, `HEAD:${target.ref}`];
83
+ export function repositoryPushArguments(selection) {
84
+ return selection === undefined ? ['push']
85
+ : ['push', '--no-follow-tags', '--', selection.remoteArgument, `HEAD:${selection.target.ref}`];
73
86
  }
74
87
 
75
88
  /** Observe an existing workspace without initializing or refreshing it. */
@@ -167,6 +180,12 @@ export async function repositoryPull({
167
180
  }={}){
168
181
  throwIfAborted(signal);
169
182
  const selectedTarget=captureRepositoryTarget(target);
183
+ return pullRepositoryTarget(selectedTarget,{workspaceRoot,signal,onEvent,run});
184
+ }
185
+
186
+ /** Internal workspace path: its target was already captured before queueing. */
187
+ export async function pullRepositoryTarget(selectedTarget,{workspaceRoot,signal,onEvent,run=runProcess}){
188
+ throwIfAborted(signal);
170
189
  const before=await repositoryStatus({workspaceRoot,signal,onEvent,run});
171
190
  if(!before.clean){
172
191
  throw new ArcaneError(
@@ -176,14 +195,14 @@ export async function repositoryPull({
176
195
  );
177
196
  }
178
197
  const args=selectedTarget===undefined?['pull','--ff-only']
179
- :['pull','--ff-only','--',selectedTarget.remote,`${selectedTarget.ref}:`];
198
+ :['pull','--ff-only','--',selectedTarget.remoteArgument,`${selectedTarget.target.ref}:`];
180
199
  const result=await git(args,{cwd:workspaceRoot,signal,onEvent,run});
181
200
  return {
182
201
  action:'pull',
183
202
  repositoryRoot:before.repositoryRoot,
184
203
  branch:before.branch,
185
204
  output:result.stdout.trim(),
186
- ...(selectedTarget===undefined?{}:{target:selectedTarget,stdout:result.stdout,stderr:result.stderr})
205
+ ...(selectedTarget===undefined?{}:{target:selectedTarget.target,stdout:result.stdout,stderr:result.stderr})
187
206
  };
188
207
  }
189
208
 
@@ -196,6 +215,12 @@ export async function repositoryPush({
196
215
  }={}){
197
216
  throwIfAborted(signal);
198
217
  const selectedTarget=captureRepositoryTarget(target);
218
+ return pushRepositoryTarget(selectedTarget,{workspaceRoot,signal,onEvent,run});
219
+ }
220
+
221
+ /** Internal workspace path: its target was already captured before queueing. */
222
+ export async function pushRepositoryTarget(selectedTarget,{workspaceRoot,signal,onEvent,run=runProcess}){
223
+ throwIfAborted(signal);
199
224
  const before=await repositoryStatus({workspaceRoot,signal,onEvent,run});
200
225
  if(!before.branch){
201
226
  throw new ArcaneError(
@@ -209,7 +234,7 @@ export async function repositoryPush({
209
234
  repositoryRoot:before.repositoryRoot,
210
235
  branch:before.branch,
211
236
  output:(result.stdout||result.stderr).trim(),
212
- ...(selectedTarget===undefined?{}:{target:selectedTarget,stdout:result.stdout,stderr:result.stderr})
237
+ ...(selectedTarget===undefined?{}:{target:selectedTarget.target,stdout:result.stdout,stderr:result.stderr})
213
238
  };
214
239
  }
215
240
 
@@ -2,6 +2,7 @@ import Is from 'strong-type';
2
2
  import {randomUUID} from 'node:crypto';
3
3
  import {copyFile,lstat,mkdir,readFile,readdir,realpath,rename,rm,writeFile} from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import {materializeBrowserDecisions} from './browser-decisions-distribution.mjs';
5
6
  import {SDK_VERSION} from './constants.mjs';
6
7
  import {rewriteAssetReferences} from './import-map.mjs';
7
8
  import {getSdkRoot} from './runtime.mjs';
@@ -85,85 +86,6 @@ async function removeTemporaryTree(location){
85
86
  await rm(location,{recursive:true,force:true}).catch(()=>{});
86
87
  }
87
88
 
88
- async function materializeBrowserDecisions(destination, signal, onEvent) {
89
- const transformers = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/';
90
- const onnx = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/';
91
- const files = [
92
- {name: 'transformers.min.js', url: `${transformers}dist/transformers.min.js`},
93
- {name: 'ort-wasm-simd-threaded.asyncify.mjs', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.mjs`},
94
- {name: 'ort-wasm-simd-threaded.asyncify.wasm', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.wasm`},
95
- {name: 'TRANSFORMERS-LICENSE', url: `${transformers}LICENSE`},
96
- {name: 'ONNX-RUNTIME-LICENSE', url: `${onnx}LICENSE`}
97
- ];
98
- const controller = new AbortController();
99
- function cancelDistribution() {
100
- controller.abort(signal.reason);
101
- }
102
- signal?.addEventListener('abort', cancelDistribution, {once: true});
103
- if (signal?.aborted) cancelDistribution();
104
- let completed = 0;
105
- try {
106
- throwIfAborted(controller.signal);
107
- await emit(
108
- onEvent,
109
- {type: 'workspace.decisions.started', completed, total: files.length}
110
- );
111
- throwIfAborted(controller.signal);
112
- await mkdir(destination, {recursive: true});
113
- async function acquireDistributionFile(file) {
114
- try {
115
- throwIfAborted(controller.signal);
116
- const response = await fetch(file.url, {signal: controller.signal});
117
- const content = new Uint8Array(await response.arrayBuffer());
118
- if (!response.ok) {
119
- const error = new Error(`Browser decision distribution returned HTTP ${response.status} ${response.statusText}.`);
120
- error.code = 'ARCANE_DECISION_DISTRIBUTION_DOWNLOAD_FAILED';
121
- error.response = {
122
- url: response.url,
123
- status: response.status,
124
- statusText: response.statusText,
125
- headers: [...response.headers],
126
- content
127
- };
128
- throw error;
129
- }
130
- throwIfAborted(controller.signal);
131
- // Upstream distribution content is opaque, including its own
132
- // imports and license text. No SDK source rewriting applies.
133
- await writeFile(path.join(destination, file.name), content);
134
- throwIfAborted(controller.signal);
135
- completed += 1;
136
- await emit(
137
- onEvent,
138
- {type: 'workspace.decisions.progress', file: file.name, url: file.url, completed, total: files.length}
139
- );
140
- } catch (error) {
141
- controller.abort(error);
142
- throw error;
143
- }
144
- }
145
- // One fixed distribution, acquired once for the selected projection;
146
- // no model/app/architecture multiplier or installation script.
147
- const outcomes = await Promise.allSettled(files.map(acquireDistributionFile));
148
- const failures = new Set();
149
- for (const outcome of outcomes) {
150
- if (outcome.status === 'rejected') failures.add(outcome.reason);
151
- }
152
- if (failures.size === 1) throw [...failures][0];
153
- if (failures.size > 1) {
154
- throw new AggregateError([...failures], 'Browser decision distribution acquisition failed.');
155
- }
156
- throwIfAborted(controller.signal);
157
- } finally {
158
- signal?.removeEventListener('abort', cancelDistribution);
159
- }
160
- return {
161
- transformersVersion: '4.3.0',
162
- onnxRuntimeVersion: '1.31.0-dev.20260914-8d85527a0',
163
- files: files.map(function distributionFilename(file) { return file.name; })
164
- };
165
- }
166
-
167
89
  export async function materializeWorkspaceRuntimeContent({
168
90
  workspaceRoot,
169
91
  runtimeRoot=path.join(getSdkRoot(),'runtime'),