@oar-sh/oar 0.9.22 → 0.9.24
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 +258 -0
- package/README.md +9 -9
- package/bin/oar.js +82 -4
- package/package.json +16 -15
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +91 -8
- package/server/public/app/bootstrap.js +204 -69
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/settings-modal.js +167 -0
- package/server/public/app/socket-handlers.js +21 -0
- package/server/public/index.html +95 -2
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/messages-routes.mjs +102 -37
- package/server/routes/sessions-routes.mjs +236 -110
- package/server/server-runtime.mjs +416 -121
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +17 -1
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +5 -3
- package/server/services/file-serving.mjs +13 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +76 -10
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/update-install-service.mjs +34 -3
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/worker-runtime/direct-openai-image.mjs +8 -4
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One place for "a provider's model list was refreshed".
|
|
4
|
+
//
|
|
5
|
+
// The ledger keeps when each provider was last refreshed (model_refresh_last)
|
|
6
|
+
// whatever asked for it: the Select Models Refresh button, a settings save, a
|
|
7
|
+
// login, a CLI install, the boot discovery, a Copilot snapshot or the
|
|
8
|
+
// background check (model-refresh-scheduler.mjs). Each refresh function is
|
|
9
|
+
// wrapped once with `track(provider, fn)`, so every caller resets the
|
|
10
|
+
// provider's clock without knowing about it. A `skipped` result (provider off,
|
|
11
|
+
// CLI missing) is neither a success nor a failure. The ledger also queues the
|
|
12
|
+
// models a refresh found for the first time, which the next `models_updated`
|
|
13
|
+
// carries as `newModels` (the toast).
|
|
14
|
+
//
|
|
15
|
+
// `refreshProviderModels` is the Promise.allSettled block of
|
|
16
|
+
// POST /api/model-variants/refresh, shared with the background check.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
MODEL_REFRESH_AUTO_SETTING_KEY,
|
|
20
|
+
MODEL_REFRESH_EXCLUDED_SETTING_KEY,
|
|
21
|
+
MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY,
|
|
22
|
+
MODEL_REFRESH_LAST_SETTING_KEY,
|
|
23
|
+
MODEL_REFRESH_PROVIDERS,
|
|
24
|
+
modelRefreshProvider,
|
|
25
|
+
parseModelRefreshAutoUpdate,
|
|
26
|
+
parseModelRefreshExcludedUpdate,
|
|
27
|
+
parseModelRefreshIntervalHoursUpdate,
|
|
28
|
+
readModelRefreshAutoSetting,
|
|
29
|
+
readModelRefreshExcludedSetting,
|
|
30
|
+
readModelRefreshIntervalHoursSetting,
|
|
31
|
+
readModelRefreshLastSetting,
|
|
32
|
+
} from '../../shared/model-refresh-schedule.mjs';
|
|
33
|
+
|
|
34
|
+
const SKIPPED = Object.freeze({ ok: true, skipped: true, models: [], error: null });
|
|
35
|
+
// What a failed refresh keeps of its error text.
|
|
36
|
+
export const MODEL_REFRESH_ERROR_MAX_CHARS = 240;
|
|
37
|
+
const REDACTED = '[redacted]';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A refresh error without the provider's secrets: each one given is cut out
|
|
41
|
+
* wherever it appears, and so is whatever follows "Bearer" or an
|
|
42
|
+
* Authorization header an answer echoed back. Then capped.
|
|
43
|
+
*/
|
|
44
|
+
export function redactModelRefreshError(text, secrets = []) {
|
|
45
|
+
let out = String(text ?? '');
|
|
46
|
+
for (const secret of Array.isArray(secrets) ? secrets : []) {
|
|
47
|
+
const value = String(secret ?? '').trim();
|
|
48
|
+
if (value.length >= 4) out = out.split(value).join(REDACTED);
|
|
49
|
+
}
|
|
50
|
+
out = out
|
|
51
|
+
.replace(/\b(authorization\s*["']?\s*[:=]\s*["']?)(?:bearer\s+)?[^\s"',;}]+/gi, `$1${REDACTED}`)
|
|
52
|
+
.replace(/\bbearer\s+(?!\[redacted\])[^\s"',;}]+/gi, `Bearer ${REDACTED}`)
|
|
53
|
+
.replace(/\s+/g, ' ')
|
|
54
|
+
.trim();
|
|
55
|
+
return out.length > MODEL_REFRESH_ERROR_MAX_CHARS ? `${out.slice(0, MODEL_REFRESH_ERROR_MAX_CHARS - 1)}…` : out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A failed refresh in a log line: its HTTP status, a timeout, or the words
|
|
60
|
+
* before the first colon, never the answer's body.
|
|
61
|
+
*/
|
|
62
|
+
export function modelRefreshErrorSummary(text) {
|
|
63
|
+
const value = String(text ?? '');
|
|
64
|
+
const status = value.match(/\((\d{3})\)/) || value.match(/\b(?:status|HTTP)\s*:?\s*(\d{3})\b/i);
|
|
65
|
+
if (status) return `HTTP ${status[1]}`;
|
|
66
|
+
if (/timed out|timeout/i.test(value)) return 'timed out';
|
|
67
|
+
const head = value.split(':')[0].replace(/\s+/g, ' ').trim();
|
|
68
|
+
return (head || 'unknown error').slice(0, 80);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isoOf(value) {
|
|
72
|
+
const date = value instanceof Date ? value : new Date(value ?? Date.now());
|
|
73
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createModelRefreshLedger({
|
|
77
|
+
readSetting = () => '',
|
|
78
|
+
writeSetting = null,
|
|
79
|
+
now = () => new Date(),
|
|
80
|
+
// (provider) => the secrets its errors may carry (API keys, tokens): cut
|
|
81
|
+
// out before an error is stored, answered by the settings route or logged.
|
|
82
|
+
secretsFor = () => [],
|
|
83
|
+
} = {}) {
|
|
84
|
+
let pendingNewModels = [];
|
|
85
|
+
|
|
86
|
+
function readLast() {
|
|
87
|
+
try {
|
|
88
|
+
return readModelRefreshLastSetting(readSetting(MODEL_REFRESH_LAST_SETTING_KEY));
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** New models of one provider for the next models_updated; one entry per provider and state. */
|
|
95
|
+
function noteNewModels(provider, added = []) {
|
|
96
|
+
const meta = modelRefreshProvider(provider);
|
|
97
|
+
if (!meta) return;
|
|
98
|
+
for (const { id, on } of Array.isArray(added) ? added : []) {
|
|
99
|
+
const modelId = String(id || '').trim();
|
|
100
|
+
if (!modelId) continue;
|
|
101
|
+
let entry = pendingNewModels.find((candidate) => candidate.provider === meta.id && candidate.on === (on === true));
|
|
102
|
+
if (!entry) {
|
|
103
|
+
entry = { provider: meta.id, tab: meta.tab, label: meta.label, ids: [], on: on === true };
|
|
104
|
+
pendingNewModels.push(entry);
|
|
105
|
+
}
|
|
106
|
+
if (!entry.ids.includes(modelId)) entry.ids.push(modelId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The queued new models, emptied. */
|
|
111
|
+
function takeNewModels() {
|
|
112
|
+
const taken = pendingNewModels;
|
|
113
|
+
pendingNewModels = [];
|
|
114
|
+
return taken;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function secretsOf(provider) {
|
|
118
|
+
try {
|
|
119
|
+
const secrets = secretsFor(provider);
|
|
120
|
+
return Array.isArray(secrets) ? secrets : [];
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Notes one refresh result; returns the provider's entry, or null when skipped. */
|
|
127
|
+
function record(provider, result) {
|
|
128
|
+
const meta = modelRefreshProvider(provider);
|
|
129
|
+
if (!meta || !result || typeof result !== 'object' || result.skipped === true) return null;
|
|
130
|
+
const stamp = isoOf(now());
|
|
131
|
+
const ok = result.ok === true;
|
|
132
|
+
const last = readLast();
|
|
133
|
+
const previous = last[meta.id] || {};
|
|
134
|
+
last[meta.id] = {
|
|
135
|
+
at: stamp,
|
|
136
|
+
ok,
|
|
137
|
+
error: ok ? null : (redactModelRefreshError(result.error || 'Model refresh failed', secretsOf(meta.id)) || 'Model refresh failed'),
|
|
138
|
+
lastOkAt: ok ? stamp : (previous.lastOkAt || null),
|
|
139
|
+
lastFailureAt: ok ? (previous.lastFailureAt || null) : stamp,
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
writeSetting?.(MODEL_REFRESH_LAST_SETTING_KEY, JSON.stringify(last), stamp);
|
|
143
|
+
} catch {
|
|
144
|
+
/* the clock is best-effort; the next refresh writes it again */
|
|
145
|
+
}
|
|
146
|
+
if (ok) noteNewModels(meta.id, result.newModels);
|
|
147
|
+
return last[meta.id];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `fn` with every call noted against `provider`; a throw counts as a failure. */
|
|
151
|
+
function track(provider, fn) {
|
|
152
|
+
if (typeof fn !== 'function') return fn;
|
|
153
|
+
return async (...args) => {
|
|
154
|
+
let result;
|
|
155
|
+
try {
|
|
156
|
+
result = await fn(...args);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
record(provider, { ok: false, error: error?.message || String(error || 'Model refresh failed') });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
record(provider, result);
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { readLast, record, track, noteNewModels, takeNewModels };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The check's own settings: `{ auto, intervalHours, excluded }`. */
|
|
170
|
+
export function createModelRefreshSettings({
|
|
171
|
+
readSetting = () => '',
|
|
172
|
+
writeSetting = null,
|
|
173
|
+
now = () => new Date(),
|
|
174
|
+
} = {}) {
|
|
175
|
+
function get() {
|
|
176
|
+
return {
|
|
177
|
+
auto: readModelRefreshAutoSetting(readSetting(MODEL_REFRESH_AUTO_SETTING_KEY)),
|
|
178
|
+
intervalHours: readModelRefreshIntervalHoursSetting(readSetting(MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY)),
|
|
179
|
+
excluded: readModelRefreshExcludedSetting(readSetting(MODEL_REFRESH_EXCLUDED_SETTING_KEY)),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Checks every field given before writing any; `{ ok, settings }` or `{ ok: false, error }`. */
|
|
184
|
+
function update(body = {}) {
|
|
185
|
+
const payload = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
186
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(payload, key);
|
|
187
|
+
if (!has('auto') && !has('intervalHours') && !has('excluded')) return { ok: false, error: 'No model refresh settings update provided' };
|
|
188
|
+
const auto = has('auto') ? parseModelRefreshAutoUpdate(payload.auto) : null;
|
|
189
|
+
const interval = has('intervalHours') ? parseModelRefreshIntervalHoursUpdate(payload.intervalHours) : null;
|
|
190
|
+
const excluded = has('excluded') ? parseModelRefreshExcludedUpdate(payload.excluded) : null;
|
|
191
|
+
const refused = [auto, interval, excluded].find((parsed) => parsed && !parsed.ok);
|
|
192
|
+
if (refused) return { ok: false, error: refused.error };
|
|
193
|
+
if (typeof writeSetting !== 'function') return { ok: false, error: 'Model refresh settings are unavailable' };
|
|
194
|
+
const stamp = isoOf(now());
|
|
195
|
+
if (auto) writeSetting(MODEL_REFRESH_AUTO_SETTING_KEY, auto.auto ? 'true' : 'false', stamp);
|
|
196
|
+
if (interval) writeSetting(MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY, String(interval.intervalHours), stamp);
|
|
197
|
+
if (excluded) writeSetting(MODEL_REFRESH_EXCLUDED_SETTING_KEY, JSON.stringify(excluded.excluded), stamp);
|
|
198
|
+
return { ok: true, settings: get() };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { get, update };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The providers the refresh can ask, from the route deps: `isEnabled` gates
|
|
206
|
+
* the Refresh button (a provider that is off is skipped), `isSetUp` the
|
|
207
|
+
* background check (Copilot: an installed CLI; Claude and Grok: their CLI
|
|
208
|
+
* or their login; the others: switched on, which needs their key),
|
|
209
|
+
* `currentModels` is what a failed refresh answers with.
|
|
210
|
+
*/
|
|
211
|
+
export function buildProviderRefreshTable({
|
|
212
|
+
getOpenAIProviderSettings = () => null,
|
|
213
|
+
refreshOpenAIProviderModels = null,
|
|
214
|
+
getClaudeProviderSettings = () => null,
|
|
215
|
+
refreshClaudeProviderModels = null,
|
|
216
|
+
getCursorProviderSettings = () => null,
|
|
217
|
+
refreshCursorProviderModels = null,
|
|
218
|
+
getGrokProviderSettings = () => null,
|
|
219
|
+
refreshGrokProviderModels = null,
|
|
220
|
+
deepseekSettingsService = null,
|
|
221
|
+
claudeApiSettingsService = null,
|
|
222
|
+
copilotModelDiscoveryService = null,
|
|
223
|
+
// Claude and Grok run on the host's CLI and its login: set up when the CLI
|
|
224
|
+
// is installed or the login is there, as the relay knows it without
|
|
225
|
+
// spawning anything. Absent (most tests): enabled is enough.
|
|
226
|
+
isClaudeSetUp = () => true,
|
|
227
|
+
isGrokSetUp = () => true,
|
|
228
|
+
} = {}) {
|
|
229
|
+
const enabledOf = (getSettings) => () => {
|
|
230
|
+
try {
|
|
231
|
+
return getSettings?.()?.enabled === true;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const modelsOf = (getSettings) => () => {
|
|
237
|
+
try {
|
|
238
|
+
const models = getSettings?.()?.models;
|
|
239
|
+
return Array.isArray(models) ? models : [];
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const entry = (getSettings, refresh, setUp = () => true) => ({
|
|
245
|
+
isEnabled: enabledOf(getSettings),
|
|
246
|
+
isSetUp: () => {
|
|
247
|
+
if (!enabledOf(getSettings)()) return false;
|
|
248
|
+
try {
|
|
249
|
+
return setUp() === true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
currentModels: modelsOf(getSettings),
|
|
255
|
+
refresh: typeof refresh === 'function' ? () => refresh() : null,
|
|
256
|
+
});
|
|
257
|
+
const deepseekSettings = () => deepseekSettingsService?.getSettings?.() || null;
|
|
258
|
+
const claudeApiSettings = () => claudeApiSettingsService?.getSettings?.() || null;
|
|
259
|
+
const hasCopilotDiscovery = typeof copilotModelDiscoveryService?.refresh === 'function';
|
|
260
|
+
return {
|
|
261
|
+
// Copilot discovery gates itself on an installed CLI (skipped when there
|
|
262
|
+
// is none), so the Refresh button always asks it.
|
|
263
|
+
github: {
|
|
264
|
+
isEnabled: () => hasCopilotDiscovery,
|
|
265
|
+
isSetUp: () => {
|
|
266
|
+
if (!hasCopilotDiscovery) return false;
|
|
267
|
+
try {
|
|
268
|
+
return typeof copilotModelDiscoveryService.isCliResolvable === 'function'
|
|
269
|
+
? copilotModelDiscoveryService.isCliResolvable() === true
|
|
270
|
+
: true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
currentModels: () => [],
|
|
276
|
+
refresh: hasCopilotDiscovery ? (reason) => copilotModelDiscoveryService.refresh(reason) : null,
|
|
277
|
+
},
|
|
278
|
+
openai: entry(getOpenAIProviderSettings, refreshOpenAIProviderModels),
|
|
279
|
+
claude: entry(getClaudeProviderSettings, refreshClaudeProviderModels, isClaudeSetUp),
|
|
280
|
+
'claude-api': entry(claudeApiSettings, claudeApiSettingsService?.refreshModels),
|
|
281
|
+
cursor: entry(getCursorProviderSettings, refreshCursorProviderModels),
|
|
282
|
+
grok: entry(getGrokProviderSettings, refreshGrokProviderModels, isGrokSetUp),
|
|
283
|
+
deepseek: entry(deepseekSettings, deepseekSettingsService?.refreshModels),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Refreshes the given providers (all by default) side by side. Returns
|
|
289
|
+
* `{ results: { <provider>: result }, changed }`: a provider that is off or
|
|
290
|
+
* has no refresh is skipped; a refresh that throws answers ok:false with the
|
|
291
|
+
* provider's current models. `changed` tells whether any provider's offered
|
|
292
|
+
* models changed or a refresh found models it had not seen before.
|
|
293
|
+
*/
|
|
294
|
+
export async function refreshProviderModels({
|
|
295
|
+
table = {},
|
|
296
|
+
providers = MODEL_REFRESH_PROVIDERS.map((provider) => provider.id),
|
|
297
|
+
reason = 'manual-refresh',
|
|
298
|
+
gate = 'isEnabled',
|
|
299
|
+
} = {}) {
|
|
300
|
+
const ids = providers.filter((id) => table[id]);
|
|
301
|
+
const before = new Map(ids.map((id) => [id, JSON.stringify(table[id].currentModels?.() || [])]));
|
|
302
|
+
const settled = await Promise.allSettled(ids.map((id) => {
|
|
303
|
+
const entry = table[id];
|
|
304
|
+
const open = typeof entry?.[gate] === 'function' ? entry[gate]() : false;
|
|
305
|
+
if (!open || typeof entry.refresh !== 'function') return Promise.resolve({ ...SKIPPED });
|
|
306
|
+
return Promise.resolve().then(() => entry.refresh(reason));
|
|
307
|
+
}));
|
|
308
|
+
const results = {};
|
|
309
|
+
let changed = false;
|
|
310
|
+
ids.forEach((id, index) => {
|
|
311
|
+
const outcome = settled[index];
|
|
312
|
+
const label = modelRefreshProvider(id)?.label || id;
|
|
313
|
+
const result = outcome.status === 'fulfilled' && outcome.value && typeof outcome.value === 'object'
|
|
314
|
+
? outcome.value
|
|
315
|
+
: {
|
|
316
|
+
ok: false,
|
|
317
|
+
models: table[id].currentModels?.() || [],
|
|
318
|
+
error: outcome.reason?.message || `${label} model discovery failed`,
|
|
319
|
+
};
|
|
320
|
+
results[id] = result;
|
|
321
|
+
if (result.skipped === true) return;
|
|
322
|
+
if (Array.isArray(result.newModels) && result.newModels.length) changed = true;
|
|
323
|
+
if (JSON.stringify(table[id].currentModels?.() || []) !== before.get(id)) changed = true;
|
|
324
|
+
});
|
|
325
|
+
return { results, changed };
|
|
326
|
+
}
|