arcane-os 0.90.0 → 0.92.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
 
@@ -15,11 +15,12 @@ const snapshotRef = 'refs/arcane/text-snapshot';
15
15
  * One application service owns each dedicated cache directory. Construction
16
16
  * performs no I/O; refresh coalesces callers and close drains accepted work.
17
17
  */
18
- export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref, selectPath, gitIdentity, onEvent, run = runProcess} = {}) {
18
+ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref, selectPath, allowUnborn = false, gitIdentity, onEvent, run = runProcess} = {}) {
19
19
  for (const [name, value] of Object.entries({cacheDirectory, remote, ref})) {
20
20
  if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
21
21
  }
22
22
  if (!is.function(selectPath)) throw new TypeError('selectPath must be a function.');
23
+ if (!is.boolean(allowUnborn)) throw new TypeError('allowUnborn must be a boolean.');
23
24
  const remoteArgument = captureRepositoryRemote(remote, remoteBase);
24
25
  const execute = createGitIdentityRunner(run, gitIdentity);
25
26
  const directory = path.resolve(cacheDirectory);
@@ -70,11 +71,28 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
70
71
  try {
71
72
  await events.send({type: 'git.snapshot.refreshing', message: 'Refreshing the selected repository revision.', data: {ref}});
72
73
  await prepare(events);
74
+ if (allowUnborn) {
75
+ // No ref pattern or --exit-code: a successful complete empty
76
+ // advertisement is distinct from a missing selected ref.
77
+ const advertised = await git(['ls-remote', '--symref', '--', remoteArgument], {onEvent: events.send});
78
+ if (advertised.code !== 0) {
79
+ throw new ArcaneError(ERROR_CODES.operationFailed, 'Git remote observation failed.', {details: advertised});
80
+ }
81
+ if (advertised.stdout === '') {
82
+ const result = {revision: null, files: [], unborn: true};
83
+ await events.send({type: 'git.snapshot.completed', message: 'The remote advertises no revision.',
84
+ data: {revision: null, unborn: true, reused: retained?.unborn === true}});
85
+ await events.drain();
86
+ retained = result;
87
+ return result;
88
+ }
89
+ }
73
90
  await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remoteArgument, `+${ref}:${snapshotRef}`], {onEvent: events.send});
74
91
  const selected = await git(['rev-parse', '--verify', `${snapshotRef}^{commit}`], {onEvent: events.send});
75
92
  const revision = selected.stdout.trim();
76
93
  if (retained?.revision === revision) {
77
- await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.', data: {revision, reused: true}});
94
+ await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.',
95
+ data: {revision, reused: true, ...(allowUnborn ? {unborn: false} : {})}});
78
96
  await events.drain();
79
97
  return retained;
80
98
  }
@@ -125,8 +143,9 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
125
143
  }
126
144
  });
127
145
  }
128
- const result = {revision, files: reader.finish()};
129
- await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.', data: {revision, reused: false}});
146
+ const result = {revision, files: reader.finish(), ...(allowUnborn ? {unborn: false} : {})};
147
+ await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.',
148
+ data: {revision, reused: false, ...(allowUnborn ? {unborn: false} : {})}});
130
149
  await events.drain();
131
150
  retained = result;
132
151
  return result;
@@ -156,7 +175,8 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
156
175
  accepted.then(settled, settled);
157
176
  }
158
177
  return waitForSnapshot(active, signal).then(function copyResult(snapshot) {
159
- return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; })};
178
+ return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; }),
179
+ ...(allowUnborn ? {unborn: snapshot.unborn} : {})};
160
180
  });
161
181
  }
162
182
 
@@ -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,
package/src/process.mjs CHANGED
@@ -3,6 +3,7 @@ import {spawn} from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import {ArcaneError,ERROR_CODES,throwIfAborted} from './errors.mjs';
5
5
  import {createEventQueue} from './event-queue.mjs';
6
+ import {windowsGitEnvironment} from './windows-git-executable.mjs';
6
7
 
7
8
  const is = new Is(false);
8
9
 
@@ -195,6 +196,29 @@ export async function runProcess(command,args=[],{
195
196
  });
196
197
  throwIfAborted(signal);
197
198
 
199
+ let childEnvironment=env?{...process.env,...env}:process.env;
200
+ if(process.platform==='win32'&&command==='git'){
201
+ try{
202
+ childEnvironment=await windowsGitEnvironment(childEnvironment,{
203
+ run:runProcess,signal,
204
+ onEvent:function discoveryEvent(event){
205
+ return events.send({
206
+ ...event,type:event.type.replace(/^process\./u,'process.git.discovery.')
207
+ });
208
+ }
209
+ });
210
+ }catch(error){
211
+ throwIfAborted(signal);
212
+ if(events.error)throw events.error;
213
+ await events.send({
214
+ type:'process.git.discovery.unavailable',
215
+ message:'Registered Git discovery was unavailable; using the existing command environment.',
216
+ data:{error}
217
+ });
218
+ }
219
+ throwIfAborted(signal);
220
+ }
221
+
198
222
  return new Promise((resolve,reject)=>{
199
223
  let stdout=outputEncodings.stdout!==null&&outputSelected(captureOutput,'stdout')?'':null;
200
224
  let stderr=outputEncodings.stderr!==null&&outputSelected(captureOutput,'stderr')?'':null;
@@ -369,7 +393,7 @@ export async function runProcess(command,args=[],{
369
393
  try{
370
394
  child=spawn(executable,executableArgs,{
371
395
  cwd,
372
- env:env?{...process.env,...env}:process.env,
396
+ env:childEnvironment,
373
397
  shell:false,
374
398
  detached:process.platform!=='win32',
375
399
  windowsHide:true,
@@ -85,6 +85,25 @@ export function repositoryPushArguments(selection) {
85
85
  : ['push', '--no-follow-tags', '--', selection.remoteArgument, `HEAD:${selection.target.ref}`];
86
86
  }
87
87
 
88
+ // Both configuration and new-clone preparation consume Git's explicit initial
89
+ // state. NUL framing keeps unusual working filenames outside these headers.
90
+ export const repositoryHeadArguments = [
91
+ '--no-optional-locks', 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=no'
92
+ ];
93
+
94
+ export function repositoryHeadState(output, details) {
95
+ const prefix = '# branch.oid ';
96
+ const record = output.split('\0').find(function branchCommit(value) {
97
+ return value.startsWith(prefix);
98
+ });
99
+ const revision = record?.slice(prefix.length);
100
+ if (!revision) {
101
+ throw new ArcaneError('ARCANE_REPOSITORY_HEAD_UNAVAILABLE',
102
+ 'Git did not report its current commit or initial branch state.', {details});
103
+ }
104
+ return revision === '(initial)' ? {revision: null, unborn: true} : {revision, unborn: false};
105
+ }
106
+
88
107
  /** Observe an existing workspace without initializing or refreshing it. */
89
108
  export async function repositoryConfiguration({workspaceRoot = process.cwd(), signal, onEvent, run = runProcess} = {}) {
90
109
  const repositoryRoot = path.resolve(workspaceRoot);
@@ -147,7 +166,12 @@ export async function repositoryConfiguration({workspaceRoot = process.cwd(), si
147
166
  upstream.urls = remotes.get(`remote.${remoteName}.url`) ?? [];
148
167
  upstream.pushUrls = remotes.get(`remote.${remoteName}.pushurl`) ?? [];
149
168
  }
150
- return {repositoryRoot, headRef, origin, upstream};
169
+ const state = await readGitText([...repositoryHeadArguments], options);
170
+ if (state.result.code !== 0) throw processFailure(state, 'Git branch state observation failed.');
171
+ let headState;
172
+ try { headState = repositoryHeadState(state.output, state.result); }
173
+ catch (error) { error.rawStdout = state.rawStdout; throw error; }
174
+ return {repositoryRoot, headRef, ...headState, origin, upstream};
151
175
  }
152
176
 
153
177
  export async function repositoryStatus({