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.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/docs/reference/ai/browser-decisions.md +45 -18
- package/docs/reference/core-repositories.md +87 -4
- package/docs/reference/git-text-snapshot.md +47 -3
- package/docs/reference/inventory/package-api.json +9 -9
- package/docs/reference/model-assets.md +63 -24
- package/docs/reference/native-decisions.md +19 -4
- package/docs/reference/sdk-api.md +95 -16
- package/package.json +1 -1
- package/src/browser-decisions-distribution.mjs +95 -0
- package/src/core/repositories.mjs +60 -3
- package/src/core/services/model-assets.mjs +256 -76
- package/src/git-text-snapshot.mjs +25 -5
- package/src/packager/core.mjs +29 -2
- package/src/process.mjs +25 -1
- package/src/repository.mjs +25 -1
- package/src/windows-git-executable.mjs +103 -0
- package/src/workspace-runtime.mjs +1 -79
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
let
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
237
|
-
record = requireProjection(id);
|
|
434
|
+
const location = await record.downloading;
|
|
238
435
|
operationSignal.throwIfAborted();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
record
|
|
243
|
-
|
|
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
|
-
|
|
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.',
|
|
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.',
|
|
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
|
|
package/src/packager/core.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
396
|
+
env:childEnvironment,
|
|
373
397
|
shell:false,
|
|
374
398
|
detached:process.platform!=='win32',
|
|
375
399
|
windowsHide:true,
|
package/src/repository.mjs
CHANGED
|
@@ -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
|
-
|
|
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({
|