dsh-qoder-connect 0.1.8 → 0.1.9
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/lib/bin.js +1 -1
- package/lib/client.js +247 -22
- package/lib/index.js +750 -117
- package/lib/{variants-DE76gF0Y.js → variants-C8ezztjV.js} +11 -2
- package/package.json +120 -116
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as QODER_GLOBAL_AUTH_PATH, B as
|
|
1
|
+
import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-C8ezztjV.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -7,8 +7,10 @@ import { createProvider } from "@earendil-works/pi-ai";
|
|
|
7
7
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
8
8
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
9
9
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import { createServer } from "node:http";
|
|
11
12
|
import { Readable } from "node:stream";
|
|
13
|
+
import yamlModule from "js-yaml";
|
|
12
14
|
//#region src/loopback.ts
|
|
13
15
|
/**
|
|
14
16
|
* Shared loopback gates for the plugin's local HTTP surfaces: the loopback
|
|
@@ -265,6 +267,17 @@ const NO_COST = {
|
|
|
265
267
|
*/
|
|
266
268
|
const RATE_SEPARATOR = " · ";
|
|
267
269
|
/**
|
|
270
|
+
* What the model seat shows in place of a rate the plugin cannot stand behind.
|
|
271
|
+
*
|
|
272
|
+
* The seat has no locale service (the adapter is a host seam), so this is a
|
|
273
|
+
* literal; it matches the settings card's own wording for the same state.
|
|
274
|
+
* Rendering it as WORDS rather than an empty slot: every other model in the
|
|
275
|
+
* seat shows `· x0.3`, so a bare name where the rate should be reads as a
|
|
276
|
+
* rendering bug. This plugin's fallback roster carries no rate at all, so the
|
|
277
|
+
* case is not hypothetical.
|
|
278
|
+
*/
|
|
279
|
+
const RATE_UNAVAILABLE = "价格暂不可用";
|
|
280
|
+
/**
|
|
268
281
|
* Append the billing rate to one model's display name.
|
|
269
282
|
*
|
|
270
283
|
* The rate rides the *name* alone: since DSH 0.1.2 the composer's model seat
|
|
@@ -280,6 +293,7 @@ const RATE_SEPARATOR = " · ";
|
|
|
280
293
|
*/
|
|
281
294
|
/** The catalog display suffix: the normalized billing rate, when the row has one. */
|
|
282
295
|
function displaySuffix(info) {
|
|
296
|
+
if (info.billing?.rateUnknown === true) return RATE_UNAVAILABLE;
|
|
283
297
|
const rate = normalizeCredits(info.billing?.credits);
|
|
284
298
|
return rate === void 0 || rate === "" ? void 0 : rate;
|
|
285
299
|
}
|
|
@@ -1615,6 +1629,175 @@ function appendHintRow(agent, kind, text) {
|
|
|
1615
1629
|
} catch {}
|
|
1616
1630
|
}
|
|
1617
1631
|
//#endregion
|
|
1632
|
+
//#region src/settings-store.ts
|
|
1633
|
+
/**
|
|
1634
|
+
* Plugin-owned settings store — the single source of truth for this plugin's
|
|
1635
|
+
* user configuration on every host line.
|
|
1636
|
+
*
|
|
1637
|
+
* WHY THIS EXISTS
|
|
1638
|
+
*
|
|
1639
|
+
* A settings write on DSH 0.1.7 goes through the profile patch
|
|
1640
|
+
* (`configEditor.edit`), which reconciles the whole loader tree and hot-reloads
|
|
1641
|
+
* the plugin's fiber (~1–1.5 s, plus a storm of client mirror refreshes) on
|
|
1642
|
+
* EVERY write — one per toggled switch. The plugin's own files (the credential,
|
|
1643
|
+
* the catalog cache) have always lived in `<profile>/.dsh-qoder-connect/`, so
|
|
1644
|
+
* the settings move there too: a write becomes a small atomic local file write
|
|
1645
|
+
* with an in-memory apply, no tree reconcile, no reload.
|
|
1646
|
+
*
|
|
1647
|
+
* FILE
|
|
1648
|
+
* <plugin data dir>/settings.json (same directory as the credential)
|
|
1649
|
+
*
|
|
1650
|
+
* The data directory is resolved by `qoderPluginDataDir()` — the ONE discovery
|
|
1651
|
+
* implementation this plugin already has (env override, then the declaring
|
|
1652
|
+
* profile, then the installing link). It is deliberately not re-implemented
|
|
1653
|
+
* here: a second copy of that logic is how a nested `.dsh-qoder-connect/
|
|
1654
|
+
* .dsh-qoder-connect/` path gets shipped.
|
|
1655
|
+
*
|
|
1656
|
+
* MIGRATION (one time)
|
|
1657
|
+
* the file is absent → seed it from the entry config's own fields → write the
|
|
1658
|
+
* file → delete ONLY this plugin's fields from the entry config (see
|
|
1659
|
+
* ./index.ts), so the profile row returns to its shipped state.
|
|
1660
|
+
*
|
|
1661
|
+
* @module dsh-qoder-connect/settings-store
|
|
1662
|
+
*/
|
|
1663
|
+
const yaml = yamlModule;
|
|
1664
|
+
const loadYaml = typeof yaml.load === "function" ? yaml.load : yaml.default?.load;
|
|
1665
|
+
/** Settings file name inside the plugin data directory. */
|
|
1666
|
+
const SETTINGS_FILE_NAME = "settings.json";
|
|
1667
|
+
/** This plugin's data directory (shared with the credential and caches). */
|
|
1668
|
+
function dataDir() {
|
|
1669
|
+
return qoderPluginDataDir();
|
|
1670
|
+
}
|
|
1671
|
+
/** Absolute path of the settings file. */
|
|
1672
|
+
function settingsFilePath() {
|
|
1673
|
+
return join(dataDir(), SETTINGS_FILE_NAME);
|
|
1674
|
+
}
|
|
1675
|
+
/** Read + parse the settings file; `undefined` when absent or unreadable. */
|
|
1676
|
+
function readFile() {
|
|
1677
|
+
const path = settingsFilePath();
|
|
1678
|
+
if (!existsSync(path)) return void 0;
|
|
1679
|
+
try {
|
|
1680
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1681
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
1682
|
+
} catch {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/** Write the settings file atomically (tmp + rename), creating the directory. */
|
|
1687
|
+
function writeSettings(values) {
|
|
1688
|
+
const path = settingsFilePath();
|
|
1689
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1690
|
+
const tmp = `${path}.tmp`;
|
|
1691
|
+
writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, "utf8");
|
|
1692
|
+
renameSync(tmp, path);
|
|
1693
|
+
}
|
|
1694
|
+
/** Resolve `$DSH_HOME` the way the Host does, without importing a Host package. */
|
|
1695
|
+
function dshHome() {
|
|
1696
|
+
const fromEnv = process.env.DSH_HOME?.trim() || void 0;
|
|
1697
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
1698
|
+
return join(homedir(), ".dsh");
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Read this plugin's sections out of the legacy settings document.
|
|
1702
|
+
*
|
|
1703
|
+
* The 0.1.5 host kept every plugin's settings in `$DSH_HOME/settings.yaml`;
|
|
1704
|
+
* 0.1.7 renames it to `settings.yaml.imported` once, on the first boot, and
|
|
1705
|
+
* imports only the sections whose names match a profile entry id. This
|
|
1706
|
+
* plugin's 0.1.5 sections were keyed by its SERVED NAMESPACES (`qoder`,
|
|
1707
|
+
* `qoder-global`, `qoder-quota`), which name no entry in the profile — the row
|
|
1708
|
+
* is `llm-qoder` — so the platform refuses them and their data survives only
|
|
1709
|
+
* in the renamed document. Reading it here recovers that data independently of
|
|
1710
|
+
* the platform's import, its section-name mapping, and its timing.
|
|
1711
|
+
*
|
|
1712
|
+
* @param namespaces - the section names the 0.1.5 host keyed this plugin's
|
|
1713
|
+
* settings under (one per served section).
|
|
1714
|
+
* @returns the union of those sections' fields, or undefined when the document
|
|
1715
|
+
* is absent, unreadable, or holds none of them.
|
|
1716
|
+
*/
|
|
1717
|
+
function readLegacySections(namespaces) {
|
|
1718
|
+
let path;
|
|
1719
|
+
for (const name of ["settings.yaml", "settings.yaml.imported"]) {
|
|
1720
|
+
const candidate = join(dshHome(), name);
|
|
1721
|
+
if (existsSync(candidate)) {
|
|
1722
|
+
path = candidate;
|
|
1723
|
+
break;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (path === void 0 || loadYaml === void 0) return void 0;
|
|
1727
|
+
let document;
|
|
1728
|
+
try {
|
|
1729
|
+
document = loadYaml(readFileSync(path, "utf8"));
|
|
1730
|
+
} catch {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (document === null || typeof document !== "object") return void 0;
|
|
1734
|
+
const merged = {};
|
|
1735
|
+
let found = false;
|
|
1736
|
+
for (const ns of namespaces) {
|
|
1737
|
+
const section = document[ns];
|
|
1738
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) continue;
|
|
1739
|
+
Object.assign(merged, section);
|
|
1740
|
+
found = true;
|
|
1741
|
+
}
|
|
1742
|
+
return found ? merged : void 0;
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Reserved bookkeeping key: how many writes the settings card has made through
|
|
1746
|
+
* this store. Its presence separates "the file holds migration seed" from "the
|
|
1747
|
+
* file holds the user's live edits" — see the seed rule in ./index.ts. Field
|
|
1748
|
+
* readers address their fields by name and never collide with it.
|
|
1749
|
+
*/
|
|
1750
|
+
const WRITE_MARK = "__writes";
|
|
1751
|
+
/**
|
|
1752
|
+
* One store instance: the plugin's own settings file, with the entry config as
|
|
1753
|
+
* the fallback layer the caller overlays it on.
|
|
1754
|
+
*/
|
|
1755
|
+
var SettingsStore = class {
|
|
1756
|
+
/** The user layer exactly as stored (presence marks an override). */
|
|
1757
|
+
user;
|
|
1758
|
+
constructor() {
|
|
1759
|
+
this.user = readFile() ?? {};
|
|
1760
|
+
}
|
|
1761
|
+
/** Whether the store file exists. */
|
|
1762
|
+
exists() {
|
|
1763
|
+
return existsSync(settingsFilePath());
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Whether the settings card has ever written through this store.
|
|
1767
|
+
*
|
|
1768
|
+
* False during the migration window — the window where a 0.1.5 settings.yaml
|
|
1769
|
+
* import (which lands only after the Loader settles, i.e. AFTER this plugin's
|
|
1770
|
+
* first apply) can still deliver the user's real values, and where a first
|
|
1771
|
+
* seed may have been taken before that import. While false the entry config
|
|
1772
|
+
* stays authoritative; once the card writes, the file is — a runtime edit
|
|
1773
|
+
* must never be regressed by a stale profile row.
|
|
1774
|
+
*/
|
|
1775
|
+
get edited() {
|
|
1776
|
+
return typeof this.user[WRITE_MARK] === "number" && this.user[WRITE_MARK] > 0;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Apply one patch in memory and persist it.
|
|
1780
|
+
* @param patch - field → value; a `null` value clears the field.
|
|
1781
|
+
* @param fromCard - true when the settings card made this write; marks the
|
|
1782
|
+
* file as holding live user edits from then on.
|
|
1783
|
+
* @returns the new user layer.
|
|
1784
|
+
*/
|
|
1785
|
+
patch(patch, fromCard = false) {
|
|
1786
|
+
const next = { ...this.user };
|
|
1787
|
+
for (const [field, value] of Object.entries(patch)) if (value === null) delete next[field];
|
|
1788
|
+
else next[field] = value;
|
|
1789
|
+
if (fromCard) next[WRITE_MARK] = (typeof next[WRITE_MARK] === "number" ? next[WRITE_MARK] : 0) + 1;
|
|
1790
|
+
writeSettings(next);
|
|
1791
|
+
for (const key of Object.keys(this.user)) delete this.user[key];
|
|
1792
|
+
Object.assign(this.user, next);
|
|
1793
|
+
return this.user;
|
|
1794
|
+
}
|
|
1795
|
+
/** The current user layer. */
|
|
1796
|
+
values() {
|
|
1797
|
+
return this.user;
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
//#endregion
|
|
1618
1801
|
//#region src/index.ts
|
|
1619
1802
|
/** Stable Cordis plugin name. */
|
|
1620
1803
|
const name = "llm-qoder";
|
|
@@ -1694,23 +1877,90 @@ function credentialPollMs() {
|
|
|
1694
1877
|
* stay in proportion under the `DSH_QODER_POLL_MS` override.
|
|
1695
1878
|
*/
|
|
1696
1879
|
const CATALOG_RETRY_SWEEPS = 10;
|
|
1880
|
+
/**
|
|
1881
|
+
* Mark one configuration field as a live projected setting, when the host has
|
|
1882
|
+
* that concept at all.
|
|
1883
|
+
*
|
|
1884
|
+
* DSH 0.1.7 projects a `.volatile()` field into the profile's settings form and
|
|
1885
|
+
* hands the plugin a STABLE REFERENCE instead of the value; a field without the
|
|
1886
|
+
* mark never reaches the form — `dsh-settings`' `describe()` skips an entry
|
|
1887
|
+
* whose schema has no volatile field (`lib/index.js:411-421`), which is exactly
|
|
1888
|
+
* what makes the browser-side `configForms` namespace exist. DSH 0.1.5's
|
|
1889
|
+
* schemastery has no `volatile` at all, so calling it there is a `TypeError`
|
|
1890
|
+
* that would take the whole plugin down with it. The method is therefore
|
|
1891
|
+
* probed, never assumed.
|
|
1892
|
+
*
|
|
1893
|
+
* Only TOP-LEVEL fields are marked: a volatile field may not sit inside another
|
|
1894
|
+
* volatile field, and schemastery rejects a volatile node reached through a
|
|
1895
|
+
* collection (`validateVolatileSchema`), while the 0.1.7 client writes a single
|
|
1896
|
+
* path segment (`ConfigFormController.set` → `path: [field]`). Every field this
|
|
1897
|
+
* plugin exposes is already a top-level member of {@link Config}, so marking
|
|
1898
|
+
* the field itself is both legal and the only granularity the write path can
|
|
1899
|
+
* address.
|
|
1900
|
+
*
|
|
1901
|
+
* @param field - the field's schema.
|
|
1902
|
+
* @returns the projected schema on a host that supports projection, else the field itself.
|
|
1903
|
+
*/
|
|
1904
|
+
function volatileField(field) {
|
|
1905
|
+
const probe = field;
|
|
1906
|
+
if (typeof probe.volatile === "function") return probe.volatile();
|
|
1907
|
+
if (typeof probe.extra === "function") return probe.extra("volatile", true);
|
|
1908
|
+
if (probe && typeof probe === "object") {
|
|
1909
|
+
probe.meta = {
|
|
1910
|
+
...probe.meta,
|
|
1911
|
+
volatile: true
|
|
1912
|
+
};
|
|
1913
|
+
return probe;
|
|
1914
|
+
}
|
|
1915
|
+
return field;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Read one configuration field through the volatile indirection.
|
|
1919
|
+
*
|
|
1920
|
+
* On DSH 0.1.7 a `.volatile()` field arrives as a reference object whose value
|
|
1921
|
+
* is read with `.get()` (`@deepseek-ai/dsh-agent-default-model`,
|
|
1922
|
+
* `lib/index.js:38-41`); on DSH 0.1.5 the plain value arrives directly. Every
|
|
1923
|
+
* read of a projected field goes through here, so both hosts yield the value
|
|
1924
|
+
* itself.
|
|
1925
|
+
*
|
|
1926
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
1927
|
+
* @param field - field name inside the configuration object.
|
|
1928
|
+
* @returns the field's value, or undefined when the field is absent.
|
|
1929
|
+
*/
|
|
1930
|
+
function readField(config, field) {
|
|
1931
|
+
const value = config?.[field];
|
|
1932
|
+
return value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Read one declared field with its declared type.
|
|
1936
|
+
*
|
|
1937
|
+
* The typed face of {@link readField}, for the few call sites that want a
|
|
1938
|
+
* single field rather than a whole {@link readConfig}.
|
|
1939
|
+
*
|
|
1940
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
1941
|
+
* @param field - field name inside the configuration object.
|
|
1942
|
+
* @returns the field's plain value, or undefined when it is absent.
|
|
1943
|
+
*/
|
|
1944
|
+
function readConfigField(config, field) {
|
|
1945
|
+
return readField(config, field);
|
|
1946
|
+
}
|
|
1697
1947
|
/** Probe authorization (shared by the plugin schema and the China section). */
|
|
1698
|
-
const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
|
|
1699
|
-
const MAXIMUM_CONTEXT_WINDOW_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)");
|
|
1948
|
+
const PROBE_CONSENT_FIELD = volatileField(z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)"));
|
|
1949
|
+
const MAXIMUM_CONTEXT_WINDOW_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)"));
|
|
1700
1950
|
/** The China variant's own maximum-window preference. */
|
|
1701
|
-
const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)");
|
|
1951
|
+
const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)"));
|
|
1702
1952
|
/**
|
|
1703
1953
|
* Per-model window overrides (model id → tokens). The card writes one entry
|
|
1704
1954
|
* per selector change; the field is optional so an absent map means "no
|
|
1705
1955
|
* overrides" rather than a default object the host must keep in sync.
|
|
1706
1956
|
*/
|
|
1707
|
-
const MODEL_CONTEXT_WINDOWS_FIELD = z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)");
|
|
1957
|
+
const MODEL_CONTEXT_WINDOWS_FIELD = volatileField(z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)"));
|
|
1708
1958
|
/** Disabled models list (blacklist) */
|
|
1709
|
-
const DISABLED_MODELS_FIELD = z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker");
|
|
1959
|
+
const DISABLED_MODELS_FIELD = volatileField(z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker"));
|
|
1710
1960
|
/** Sidebar quota toggle (one per variant; both live on the shared quota card). */
|
|
1711
|
-
const QUOTA_TOGGLE_FIELD = z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)");
|
|
1961
|
+
const QUOTA_TOGGLE_FIELD = volatileField(z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)"));
|
|
1712
1962
|
/** Automatic check-in toggle. */
|
|
1713
|
-
const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)");
|
|
1963
|
+
const AUTO_CHECK_IN_FIELD = volatileField(z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)"));
|
|
1714
1964
|
/**
|
|
1715
1965
|
* When a variant checks in, as minutes past midnight in UTC+8.
|
|
1716
1966
|
*
|
|
@@ -1719,7 +1969,7 @@ const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天自动
|
|
|
1719
1969
|
* anything outside that range fails Host validation instead of silently
|
|
1720
1970
|
* scheduling a request at a moment that never arrives.
|
|
1721
1971
|
*/
|
|
1722
|
-
const CHECK_IN_MINUTE_FIELD = z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)");
|
|
1972
|
+
const CHECK_IN_MINUTE_FIELD = volatileField(z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)"));
|
|
1723
1973
|
/**
|
|
1724
1974
|
* Quota poll interval: default 5 minutes, floor 1 minute. The status route
|
|
1725
1975
|
* performs a live upstream billing call per request with no cache, so an
|
|
@@ -1729,13 +1979,15 @@ const CHECK_IN_MINUTE_FIELD = z.number().default(600).min(0).max(1439).descripti
|
|
|
1729
1979
|
*/
|
|
1730
1980
|
const QUOTA_POLL_DEFAULT_MS = 3e5;
|
|
1731
1981
|
const QUOTA_POLL_MIN_MS = 6e4;
|
|
1732
|
-
const QUOTA_POLL_FIELD = z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)");
|
|
1982
|
+
const QUOTA_POLL_FIELD = volatileField(z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)"));
|
|
1733
1983
|
const Config = z.object({
|
|
1734
1984
|
probeConsent: PROBE_CONSENT_FIELD,
|
|
1735
1985
|
useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
1736
1986
|
useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
|
|
1737
1987
|
modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1738
1988
|
modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1989
|
+
disabledModels: DISABLED_MODELS_FIELD,
|
|
1990
|
+
disabledModelsCN: DISABLED_MODELS_FIELD,
|
|
1739
1991
|
sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
|
|
1740
1992
|
sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
|
|
1741
1993
|
autoCheckInCN: AUTO_CHECK_IN_FIELD,
|
|
@@ -1744,35 +1996,18 @@ const Config = z.object({
|
|
|
1744
1996
|
checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
|
|
1745
1997
|
quotaPollMs: QUOTA_POLL_FIELD
|
|
1746
1998
|
});
|
|
1747
|
-
|
|
1748
|
-
* The China card's settings section: only the fields that card edits.
|
|
1749
|
-
*
|
|
1750
|
-
* A section is what makes its namespace "served", which is what the Plugins
|
|
1751
|
-
* tab dispatches a card by — so the schema and the card must stay split the
|
|
1752
|
-
* same way. `probeConsent` lives here because it predates the second variant;
|
|
1753
|
-
* it gates no current code path (only manual, per-click-confirmed probes run),
|
|
1754
|
-
* so it is left where existing users set it rather than moved and re-asked.
|
|
1755
|
-
*/
|
|
1756
|
-
const CHINA_SECTION = z.object({
|
|
1999
|
+
z.object({
|
|
1757
2000
|
probeConsent: PROBE_CONSENT_FIELD,
|
|
1758
2001
|
useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
|
|
1759
2002
|
modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1760
2003
|
disabledModelsCN: DISABLED_MODELS_FIELD
|
|
1761
2004
|
});
|
|
1762
|
-
|
|
1763
|
-
const GLOBAL_SECTION = z.object({
|
|
2005
|
+
z.object({
|
|
1764
2006
|
useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
1765
2007
|
modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1766
2008
|
disabledModels: DISABLED_MODELS_FIELD
|
|
1767
2009
|
});
|
|
1768
|
-
|
|
1769
|
-
* The shared quota card's section: both sidebar toggles and the poll interval.
|
|
1770
|
-
*
|
|
1771
|
-
* Only these fields — the card edits nothing else, and the Plugins tab pairs a
|
|
1772
|
-
* card with the section whose namespace it names, so a stray field here would
|
|
1773
|
-
* render as a control no other surface reads.
|
|
1774
|
-
*/
|
|
1775
|
-
const QUOTA_SECTION = z.object({
|
|
2010
|
+
z.object({
|
|
1776
2011
|
sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
|
|
1777
2012
|
sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
|
|
1778
2013
|
autoCheckInCN: AUTO_CHECK_IN_FIELD,
|
|
@@ -1813,13 +2048,37 @@ const QUOTA_SECTION_KEYS = [
|
|
|
1813
2048
|
"checkInMinuteGlobal",
|
|
1814
2049
|
"quotaPollMs"
|
|
1815
2050
|
];
|
|
2051
|
+
/** Every declared configuration field, in the order the sections are merged. */
|
|
2052
|
+
const CONFIG_KEYS = [
|
|
2053
|
+
...CN_SECTION_KEYS,
|
|
2054
|
+
...GLOBAL_SECTION_KEYS,
|
|
2055
|
+
...QUOTA_SECTION_KEYS
|
|
2056
|
+
];
|
|
1816
2057
|
/** Copy the declared fields off one section's source, skipping absent ones. */
|
|
1817
2058
|
function pickFields(source, keys) {
|
|
1818
2059
|
const value = source();
|
|
1819
2060
|
const picked = {};
|
|
1820
|
-
for (const key of keys)
|
|
2061
|
+
for (const key of keys) {
|
|
2062
|
+
const field = readField(value, key);
|
|
2063
|
+
if (field !== void 0) picked[key] = field;
|
|
2064
|
+
}
|
|
1821
2065
|
return picked;
|
|
1822
2066
|
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Unwrap a whole configuration object into plain values.
|
|
2069
|
+
*
|
|
2070
|
+
* The merged configuration is read through {@link pickFields}/{@link readField}
|
|
2071
|
+
* everywhere else, but the plugin also reads its initial config directly while
|
|
2072
|
+
* it assembles its runtimes — before any settings section has handed over a
|
|
2073
|
+
* source. On 0.1.7 those direct reads would otherwise compare a reference
|
|
2074
|
+
* object against `true`/`undefined` and silently see "not set" for every field.
|
|
2075
|
+
*
|
|
2076
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
2077
|
+
* @returns the same configuration with every declared field read out.
|
|
2078
|
+
*/
|
|
2079
|
+
function readConfig(config) {
|
|
2080
|
+
return pickFields(() => config, CONFIG_KEYS);
|
|
2081
|
+
}
|
|
1823
2082
|
/** Stable identity key used by credentials, probe records, and catalog entries. */
|
|
1824
2083
|
const credentialIdentity = qoderCredentialIdentity;
|
|
1825
2084
|
/** The settings namespace a variant's card and provider directory entry use. */
|
|
@@ -1901,13 +2160,14 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
|
1901
2160
|
});
|
|
1902
2161
|
const fallback = fallbackFor(variant);
|
|
1903
2162
|
const catalog = new QoderCatalog(fallback);
|
|
2163
|
+
const initial = current();
|
|
1904
2164
|
if (variant.id !== CHINA_VARIANT.id) {
|
|
1905
|
-
catalog.setUseMaximumContextWindow(
|
|
1906
|
-
if (
|
|
2165
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindow === true);
|
|
2166
|
+
if (initial.disabledModels !== void 0) catalog.setDisabledModels(initial.disabledModels);
|
|
1907
2167
|
} else {
|
|
1908
|
-
catalog.setUseMaximumContextWindow(
|
|
1909
|
-
if (
|
|
1910
|
-
if (
|
|
2168
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindowCN === true);
|
|
2169
|
+
if (initial.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(initial.modelContextWindowsCN);
|
|
2170
|
+
if (initial.disabledModelsCN !== void 0) catalog.setDisabledModels(initial.disabledModelsCN);
|
|
1911
2171
|
}
|
|
1912
2172
|
catalog.setVisible(false);
|
|
1913
2173
|
const probeStore = new QoderProbeStore({
|
|
@@ -2010,9 +2270,11 @@ function detach(ctx, work, what) {
|
|
|
2010
2270
|
* models read the shim origin at construction time. A failure here is
|
|
2011
2271
|
* contained to this variant: the caller logs it and the other keeps working.
|
|
2012
2272
|
*
|
|
2273
|
+
* @param seedCatalog - adopt the current credential into this runtime's
|
|
2274
|
+
* catalog immediately; supplied by `apply` (it owns `adoptIdentity`).
|
|
2013
2275
|
* @returns whether the provider registered.
|
|
2014
2276
|
*/
|
|
2015
|
-
async function startVariant(ctx, runtime) {
|
|
2277
|
+
async function startVariant(ctx, runtime, seedCatalog) {
|
|
2016
2278
|
const { variant, store, client, catalog, probeService } = runtime;
|
|
2017
2279
|
const shim = createQoderShim({
|
|
2018
2280
|
store,
|
|
@@ -2045,10 +2307,19 @@ async function startVariant(ctx, runtime) {
|
|
|
2045
2307
|
let releaseDirectory;
|
|
2046
2308
|
try {
|
|
2047
2309
|
releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
|
|
2310
|
+
const host017 = (() => {
|
|
2311
|
+
try {
|
|
2312
|
+
return ctx.get?.("configEditor") !== void 0;
|
|
2313
|
+
} catch {
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
})();
|
|
2317
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
2318
|
+
const settingsNs = host017 && entryId !== void 0 ? entryId : settingsNamespaceFor(variant);
|
|
2048
2319
|
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
2049
2320
|
provider: variant.id,
|
|
2050
2321
|
displayName: variant.displayName,
|
|
2051
|
-
settingsNs
|
|
2322
|
+
settingsNs,
|
|
2052
2323
|
settingsPath: [],
|
|
2053
2324
|
declared: false
|
|
2054
2325
|
}]);
|
|
@@ -2070,6 +2341,7 @@ async function startVariant(ctx, runtime) {
|
|
|
2070
2341
|
detach(ctx, shim.close(), "loopback endpoint close");
|
|
2071
2342
|
}
|
|
2072
2343
|
runtime.registered = true;
|
|
2344
|
+
detach(ctx, seedCatalog(), `${variant.id} catalog seed`);
|
|
2073
2345
|
return true;
|
|
2074
2346
|
} catch (error) {
|
|
2075
2347
|
ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
|
|
@@ -2078,6 +2350,305 @@ async function startVariant(ctx, runtime) {
|
|
|
2078
2350
|
}
|
|
2079
2351
|
}
|
|
2080
2352
|
/**
|
|
2353
|
+
* State that MUST survive a fiber reload, module-level on purpose.
|
|
2354
|
+
*
|
|
2355
|
+
* DSH 0.1.7's configuration write (`configEditor.edit`) reconciles the profile
|
|
2356
|
+
* tree, which hot-reloads the entry's fiber — `apply()` runs again with a fresh
|
|
2357
|
+
* closure. Anything re-minted per apply is therefore invalidated by every
|
|
2358
|
+
* settings write: the browser cards hold the keys the status document handed
|
|
2359
|
+
* them, so a per-apply key turns each write into a wave of 403s ("刷新失败"),
|
|
2360
|
+
* and a per-apply identity map makes the sweep re-fetch the catalog from
|
|
2361
|
+
* upstream on every write (the burst of requests and most of the latency).
|
|
2362
|
+
*
|
|
2363
|
+
* Keys and identity/fetch bookkeeping are per-PROCESS secrets and caches, not
|
|
2364
|
+
* per-instance state, so they live here once. A reload keeps the same keys (no
|
|
2365
|
+
* 403 storm) and a same-account, freshly-fetched sweep (no refetch burst).
|
|
2366
|
+
*/
|
|
2367
|
+
/** The in-process control keys, minted once per process. */
|
|
2368
|
+
let processKeys;
|
|
2369
|
+
function controlKeys() {
|
|
2370
|
+
processKeys ??= {
|
|
2371
|
+
probe: createProbeKey(),
|
|
2372
|
+
auth: createAuthKey()
|
|
2373
|
+
};
|
|
2374
|
+
return processKeys;
|
|
2375
|
+
}
|
|
2376
|
+
/** The loopback guard the probe and status routes use, restated for settings. */
|
|
2377
|
+
function trustedSettingsRequest(req) {
|
|
2378
|
+
const host = req.headers.host ?? "";
|
|
2379
|
+
const origin = req.headers.origin;
|
|
2380
|
+
if (!/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(host))) return false;
|
|
2381
|
+
return origin === void 0 || /^https?:\/\/(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(origin));
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* The schema defaults, READ off each field's own `meta.default`.
|
|
2385
|
+
*
|
|
2386
|
+
* Two ways this was got wrong before, both silent and both destructive: a
|
|
2387
|
+
* hand-written table drifted from the schema (so the schema's own defaults
|
|
2388
|
+
* were classified as real overrides and the legacy settings document could
|
|
2389
|
+
* never win), and `validate({})` was then tried instead — which on this
|
|
2390
|
+
* schemastery answers with hollow `{}` per field instead of applying the
|
|
2391
|
+
* defaults, which is worse than nothing because it looks like it worked.
|
|
2392
|
+
* Walking `Config.dict` for `meta.default` is the only honest source: it is
|
|
2393
|
+
* the value schemastery itself substituted during validation.
|
|
2394
|
+
*/
|
|
2395
|
+
const DEFAULT_FOR_FIELD = (() => {
|
|
2396
|
+
const out = {};
|
|
2397
|
+
try {
|
|
2398
|
+
for (const [key, field] of Object.entries(Config.dict ?? {})) {
|
|
2399
|
+
const fallback = field?.meta?.default;
|
|
2400
|
+
if (fallback !== void 0) out[key] = fallback;
|
|
2401
|
+
}
|
|
2402
|
+
} catch {}
|
|
2403
|
+
if (Object.keys(out).length === 0) return {
|
|
2404
|
+
probeConsent: false,
|
|
2405
|
+
useMaximumContextWindow: true,
|
|
2406
|
+
useMaximumContextWindowCN: true,
|
|
2407
|
+
modelContextWindows: {},
|
|
2408
|
+
modelContextWindowsCN: {},
|
|
2409
|
+
disabledModels: [],
|
|
2410
|
+
disabledModelsCN: [],
|
|
2411
|
+
sidebarQuotaCN: false,
|
|
2412
|
+
sidebarQuotaGlobal: false,
|
|
2413
|
+
autoCheckInCN: false,
|
|
2414
|
+
autoCheckInGlobal: false,
|
|
2415
|
+
checkInMinuteCN: 600,
|
|
2416
|
+
checkInMinuteGlobal: 600,
|
|
2417
|
+
quotaPollMs: 3e5
|
|
2418
|
+
};
|
|
2419
|
+
return out;
|
|
2420
|
+
})();
|
|
2421
|
+
/**
|
|
2422
|
+
* Register the settings face (GET/POST) the browser cards read and write
|
|
2423
|
+
* through, answering the whole entry configuration as three layers.
|
|
2424
|
+
*
|
|
2425
|
+
* `value` carries every declared field, so a card rendering the quota section
|
|
2426
|
+
* sees the same merged view the host itself reads; `user` is the settings file
|
|
2427
|
+
* exactly as stored (presence marks an override), which is what the staged-edit
|
|
2428
|
+
* form's "written/reset" display needs. A POST validates the fields it is
|
|
2429
|
+
* allowed to touch, writes the file, applies the new view in memory, and
|
|
2430
|
+
* re-arms the check-in scheduler (the old 0.1.5 section `onChange` behaviour).
|
|
2431
|
+
*/
|
|
2432
|
+
function registerQoderSettingsFace(ctx, deps) {
|
|
2433
|
+
const { store, config, current, apply, rearm } = deps;
|
|
2434
|
+
const key = controlKeys().probe;
|
|
2435
|
+
/** The document both routes answer with. */
|
|
2436
|
+
const view = () => {
|
|
2437
|
+
const merged = current();
|
|
2438
|
+
const value = {};
|
|
2439
|
+
const base = {};
|
|
2440
|
+
for (const field of CONFIG_KEYS) {
|
|
2441
|
+
if (merged[field] !== void 0) value[field] = merged[field];
|
|
2442
|
+
base[field] = readConfig(config)[field] ?? DEFAULT_FOR_FIELD[field];
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
key,
|
|
2446
|
+
value,
|
|
2447
|
+
base,
|
|
2448
|
+
user: store.values()
|
|
2449
|
+
};
|
|
2450
|
+
};
|
|
2451
|
+
ctx.effect(() => {
|
|
2452
|
+
const dispose = ctx.webServer.register({
|
|
2453
|
+
kind: "exact",
|
|
2454
|
+
path: QODER_SETTINGS_FACE_PATH,
|
|
2455
|
+
handler: async (req, res) => {
|
|
2456
|
+
if (!trustedSettingsRequest(req)) {
|
|
2457
|
+
jsonFace(res, 403, { error: "request-not-trusted" });
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (req.method === "GET") {
|
|
2461
|
+
jsonFace(res, 200, view());
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (req.method !== "POST") {
|
|
2465
|
+
jsonFace(res, 405, { error: "method not allowed" });
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (req.headers["x-qoder-settings-key"] !== key) {
|
|
2469
|
+
jsonFace(res, 403, { error: "invalid-key" });
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
const body = await readFaceBody(req);
|
|
2473
|
+
if (body === void 0) {
|
|
2474
|
+
jsonFace(res, 413, { error: "body too large" });
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
let patch;
|
|
2478
|
+
try {
|
|
2479
|
+
patch = JSON.parse(body || "{}");
|
|
2480
|
+
} catch {
|
|
2481
|
+
jsonFace(res, 400, { error: "invalid json" });
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
|
|
2485
|
+
jsonFace(res, 400, { error: "invalid patch" });
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
const invalid = validateSettingsPatch(patch);
|
|
2489
|
+
if (invalid !== void 0) {
|
|
2490
|
+
jsonFace(res, 400, { error: invalid });
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
store.patch(patch, true);
|
|
2494
|
+
apply(current());
|
|
2495
|
+
rearm();
|
|
2496
|
+
jsonFace(res, 200, view());
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
return () => {
|
|
2500
|
+
dispose();
|
|
2501
|
+
};
|
|
2502
|
+
}, "dsh-qoder-connect: settings face");
|
|
2503
|
+
}
|
|
2504
|
+
/** JSON response helper for the settings face. */
|
|
2505
|
+
function jsonFace(res, status, body) {
|
|
2506
|
+
const payload = JSON.stringify(body);
|
|
2507
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2508
|
+
res.end(payload);
|
|
2509
|
+
}
|
|
2510
|
+
/** Read the request body, or undefined when absent or oversized. */
|
|
2511
|
+
function readFaceBody(req) {
|
|
2512
|
+
return new Promise((resolve) => {
|
|
2513
|
+
let body = "";
|
|
2514
|
+
req.on("data", (chunk) => {
|
|
2515
|
+
body += String(chunk);
|
|
2516
|
+
if (body.length > 1e6) resolve(void 0);
|
|
2517
|
+
});
|
|
2518
|
+
req.on("end", () => resolve(body));
|
|
2519
|
+
req.on("error", () => resolve(void 0));
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
/** Schema-level validation of one settings patch; the reason, or undefined. */
|
|
2523
|
+
function validateSettingsPatch(patch) {
|
|
2524
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
2525
|
+
if (!CONFIG_KEYS.includes(field)) return `unknown field ${field}`;
|
|
2526
|
+
if (value === null) continue;
|
|
2527
|
+
switch (field) {
|
|
2528
|
+
case "probeConsent":
|
|
2529
|
+
case "useMaximumContextWindow":
|
|
2530
|
+
case "useMaximumContextWindowCN":
|
|
2531
|
+
case "sidebarQuotaCN":
|
|
2532
|
+
case "sidebarQuotaGlobal":
|
|
2533
|
+
case "autoCheckInCN":
|
|
2534
|
+
case "autoCheckInGlobal":
|
|
2535
|
+
if (typeof value !== "boolean") return `${field} must be a boolean`;
|
|
2536
|
+
break;
|
|
2537
|
+
case "disabledModels":
|
|
2538
|
+
case "disabledModelsCN":
|
|
2539
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${field} must be an array of strings`;
|
|
2540
|
+
break;
|
|
2541
|
+
case "checkInMinuteCN":
|
|
2542
|
+
case "checkInMinuteGlobal":
|
|
2543
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 1439) return `${field} must be an integer minute 0..1439`;
|
|
2544
|
+
break;
|
|
2545
|
+
case "quotaPollMs":
|
|
2546
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 6e4) return `${field} must be an integer of at least 60000 ms`;
|
|
2547
|
+
break;
|
|
2548
|
+
case "modelContextWindows":
|
|
2549
|
+
case "modelContextWindowsCN": if (typeof value !== "object" || value === null || Array.isArray(value)) return `${field} must be an object`;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* When the profile tree last recomposed, module-level so every apply sees it.
|
|
2555
|
+
*
|
|
2556
|
+
* The legacy settings.yaml import writes the profile patch one section at a
|
|
2557
|
+
* time for seconds after the Loader settles, emitting this event on every
|
|
2558
|
+
* write. Cleaning up our row in that window is futile — the import's next
|
|
2559
|
+
* section simply writes it back — so the cleanup waits for the tree to fall
|
|
2560
|
+
* quiet first.
|
|
2561
|
+
*/
|
|
2562
|
+
let lastConfigReloadAt = Date.now();
|
|
2563
|
+
/** Wait until the profile tree has been quiet (the legacy import finished). */
|
|
2564
|
+
async function waitForProfileQuiet() {
|
|
2565
|
+
const deadline = Date.now() + 12e4;
|
|
2566
|
+
for (;;) {
|
|
2567
|
+
if (Date.now() - lastConfigReloadAt >= 5e3) return;
|
|
2568
|
+
if (Date.now() >= deadline) return;
|
|
2569
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Delete this plugin's own fields from the profile entry config, leaving every
|
|
2574
|
+
* other key of the row untouched.
|
|
2575
|
+
*
|
|
2576
|
+
* One-time, right after the settings file has been seeded: the entry returns to
|
|
2577
|
+
* its shipped state, so no second source of truth remains. Both write APIs are
|
|
2578
|
+
* probed — `configEditor` (0.1.7) first, then the settings service's namespace
|
|
2579
|
+
* `replace` (0.1.5), which rebuilds each of this plugin's three sections from
|
|
2580
|
+
* the foreign keys alone.
|
|
2581
|
+
*
|
|
2582
|
+
* @param ctx - plugin context.
|
|
2583
|
+
* @param ownKeys - this plugin's declared config fields.
|
|
2584
|
+
* @param namespaces - the 0.1.5 section namespaces this plugin owns.
|
|
2585
|
+
*/
|
|
2586
|
+
async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
|
|
2587
|
+
const attempts = 10;
|
|
2588
|
+
for (let attempt = 0; attempt < attempts; attempt++) try {
|
|
2589
|
+
if (attempt === 0) await waitForProfileQuiet();
|
|
2590
|
+
else await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
|
|
2591
|
+
const probe = ctx;
|
|
2592
|
+
const editor = (typeof probe.get === "function" ? (() => {
|
|
2593
|
+
try {
|
|
2594
|
+
return probe.get.call(ctx, "configEditor");
|
|
2595
|
+
} catch {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
})() : void 0) ?? await new Promise((resolve) => {
|
|
2599
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2600
|
+
const owner = settingsCtx.settings?.ownerContext;
|
|
2601
|
+
resolve(typeof owner?.get === "function" ? (() => {
|
|
2602
|
+
try {
|
|
2603
|
+
return owner.get.call(owner, "configEditor");
|
|
2604
|
+
} catch {
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
})() : void 0);
|
|
2608
|
+
});
|
|
2609
|
+
});
|
|
2610
|
+
const entry = probe.fiber?.entry;
|
|
2611
|
+
if (editor !== void 0 && entry !== void 0) {
|
|
2612
|
+
await editor.edit(entry, (raw, inherited) => {
|
|
2613
|
+
const next = { ...inherited ?? {} };
|
|
2614
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
2615
|
+
if (ownKeys.includes(key)) continue;
|
|
2616
|
+
if (!Object.hasOwn(next, key)) next[key] = value;
|
|
2617
|
+
}
|
|
2618
|
+
return next;
|
|
2619
|
+
});
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
await new Promise((resolve) => {
|
|
2623
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2624
|
+
const settings = settingsCtx.settings;
|
|
2625
|
+
if (typeof settings?.replace !== "function" || typeof settings.installSection !== "function") {
|
|
2626
|
+
resolve(void 0);
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
Promise.all(namespaces.map((ns) => Promise.resolve(settings.replace(ns, {})).catch(() => void 0))).then(() => resolve(void 0));
|
|
2630
|
+
});
|
|
2631
|
+
});
|
|
2632
|
+
return;
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
if (attempt === 9) {
|
|
2635
|
+
ctx.logger?.warn?.("dsh-qoder-connect: entry config cleanup failed", error);
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1) + Math.random() * 500));
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
/** The account identity each variant last published a catalog for, across reloads. */
|
|
2642
|
+
const lastIdentities = /* @__PURE__ */ new Map();
|
|
2643
|
+
/**
|
|
2644
|
+
* When a variant's catalog was last fetched LIVE from upstream, per identity.
|
|
2645
|
+
*
|
|
2646
|
+
* A reload reseeds the catalog from the saved file; without this record the
|
|
2647
|
+
* sweep's retry rule (`stale && due`) sees `lastFetchAtMs = 0` and re-fetches
|
|
2648
|
+
* immediately — one upstream round trip per variant per settings write.
|
|
2649
|
+
*/
|
|
2650
|
+
const lastLiveFetchAt = /* @__PURE__ */ new Map();
|
|
2651
|
+
/**
|
|
2081
2652
|
* Start both variants: their loopback endpoints, the `qoder` and
|
|
2082
2653
|
* `qoder-global` providers, their configuration cards, and their
|
|
2083
2654
|
* credential-driven catalog lifecycles.
|
|
@@ -2088,16 +2659,72 @@ async function startVariant(ctx, runtime) {
|
|
|
2088
2659
|
* without re-registering the provider.
|
|
2089
2660
|
*/
|
|
2090
2661
|
function apply(ctx, config) {
|
|
2091
|
-
|
|
2662
|
+
const store = new SettingsStore();
|
|
2663
|
+
let current = () => ({
|
|
2664
|
+
...readConfig(config),
|
|
2665
|
+
...store.values()
|
|
2666
|
+
});
|
|
2667
|
+
/**
|
|
2668
|
+
* One-time migration: per field — the file never held it → take the entry;
|
|
2669
|
+
* the card HAS written this file → keep the file; the entry carries a
|
|
2670
|
+
* NON-DEFAULT value → take the entry (the legacy settings.yaml import lands
|
|
2671
|
+
* only after the Loader settles, i.e. after this plugin's first apply, and a
|
|
2672
|
+
* seed taken inside that window can hold a stale or mis-encoded value); the
|
|
2673
|
+
* entry only carries the schema default → keep the file.
|
|
2674
|
+
*
|
|
2675
|
+
* That last clause is load-bearing: a bundle layer's insert config does NOT
|
|
2676
|
+
* reach the composition (verified with `dsh --dump-config`), so once the
|
|
2677
|
+
* one-time cleanup has emptied the entry row the entry answers pure defaults —
|
|
2678
|
+
* treating those as authoritative would erase the user's values on the next
|
|
2679
|
+
* boot. Re-checked on every apply, which is also what closes the legacy
|
|
2680
|
+
* import's timing window.
|
|
2681
|
+
*
|
|
2682
|
+
* When the entry is authoritative, its own fields are then deleted from the
|
|
2683
|
+
* profile row, so no second source of truth remains.
|
|
2684
|
+
*/
|
|
2685
|
+
const migrateOwnSettings = () => {
|
|
2686
|
+
const legacy = readLegacySections([
|
|
2687
|
+
QODER_SETTINGS_NS,
|
|
2688
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2689
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2690
|
+
]);
|
|
2691
|
+
const seeded = {};
|
|
2692
|
+
for (const key of CONFIG_KEYS) {
|
|
2693
|
+
const entryValue = readField(config, key);
|
|
2694
|
+
const legacyValue = legacy === void 0 ? void 0 : legacy[key];
|
|
2695
|
+
if (!Object.hasOwn(store.user, key)) {
|
|
2696
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
|
|
2697
|
+
else if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2698
|
+
else if (entryValue !== void 0) seeded[key] = entryValue;
|
|
2699
|
+
else if (legacyValue !== void 0) seeded[key] = legacyValue;
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
if (store.edited) continue;
|
|
2703
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) {
|
|
2704
|
+
seeded[key] = entryValue;
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2708
|
+
}
|
|
2709
|
+
if (Object.keys(seeded).length === 0) return;
|
|
2710
|
+
store.patch(seeded);
|
|
2711
|
+
cleanupEntryConfig(ctx, CONFIG_KEYS, [
|
|
2712
|
+
QODER_SETTINGS_NS,
|
|
2713
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2714
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2715
|
+
]);
|
|
2716
|
+
};
|
|
2717
|
+
migrateOwnSettings();
|
|
2718
|
+
const eventSink = ctx;
|
|
2719
|
+
eventSink.on("loader/volatile-update", () => {
|
|
2720
|
+
migrateOwnSettings();
|
|
2721
|
+
});
|
|
2722
|
+
eventSink.on("app-boot/config-reload", () => {
|
|
2723
|
+
lastConfigReloadAt = Date.now();
|
|
2724
|
+
});
|
|
2092
2725
|
/** Timers and in-flight work belonging to this plugin instance. */
|
|
2093
2726
|
let stopped = false;
|
|
2094
2727
|
const timers = [];
|
|
2095
|
-
/**
|
|
2096
|
-
* The account identity each variant last published a catalog for. Keeps a
|
|
2097
|
-
* same-token sweep from re-fetching, and lets a late response from a
|
|
2098
|
-
* previous identity be discarded instead of overwriting a newer one.
|
|
2099
|
-
*/
|
|
2100
|
-
const lastIdentities = /* @__PURE__ */ new Map();
|
|
2101
2728
|
const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
|
|
2102
2729
|
const checkInStore = new JsonFileCheckInStore();
|
|
2103
2730
|
const checkInScheduler = new CheckInScheduler({
|
|
@@ -2135,17 +2762,7 @@ function apply(ctx, config) {
|
|
|
2135
2762
|
checkInScheduler.catchUp();
|
|
2136
2763
|
};
|
|
2137
2764
|
installJobTokenHint(ctx);
|
|
2138
|
-
const probeKey =
|
|
2139
|
-
/**
|
|
2140
|
-
* The in-process key authorizing PAT writes, minted separately from the
|
|
2141
|
-
* probe key.
|
|
2142
|
-
*
|
|
2143
|
-
* Separate keys rather than one shared secret because the two authorize
|
|
2144
|
-
* different powers: one spends credit on a probe, the other stores a
|
|
2145
|
-
* credential. A single key handed to both would let a defect in either card
|
|
2146
|
-
* reach the other's authority.
|
|
2147
|
-
*/
|
|
2148
|
-
const authKey = createAuthKey();
|
|
2765
|
+
const { probe: probeKey, auth: authKey } = controlKeys();
|
|
2149
2766
|
let setMaximumContextWindow;
|
|
2150
2767
|
let setMaximumContextWindowCN;
|
|
2151
2768
|
let setModelsEnabled;
|
|
@@ -2173,7 +2790,7 @@ function apply(ctx, config) {
|
|
|
2173
2790
|
const adoptIdentity = (runtime, identity) => {
|
|
2174
2791
|
const id = runtime.variant.id;
|
|
2175
2792
|
const known = lastIdentities.get(id);
|
|
2176
|
-
if (known === identity) return;
|
|
2793
|
+
if (known === identity && runtime.catalog.isVisible()) return;
|
|
2177
2794
|
const hadCredential = known !== void 0;
|
|
2178
2795
|
if (identity === void 0) lastIdentities.delete(id);
|
|
2179
2796
|
else lastIdentities.set(id, identity);
|
|
@@ -2207,6 +2824,26 @@ function apply(ctx, config) {
|
|
|
2207
2824
|
runtime.catalog.setVisible(true);
|
|
2208
2825
|
runtime.invalidate();
|
|
2209
2826
|
};
|
|
2827
|
+
/**
|
|
2828
|
+
* Apply catalog preferences from a configuration view, in memory.
|
|
2829
|
+
*
|
|
2830
|
+
* Shared by the probe-route setters and the settings face's POST: both write
|
|
2831
|
+
* the plugin-owned settings file and then land the new view here, which is
|
|
2832
|
+
* what the cards see on their next status read.
|
|
2833
|
+
*/
|
|
2834
|
+
const applyCatalogSettings = (next) => {
|
|
2835
|
+
let changed = false;
|
|
2836
|
+
for (const runtime of runtimes) {
|
|
2837
|
+
const isChina = runtime.variant.id === CHINA_VARIANT.id;
|
|
2838
|
+
const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
|
|
2839
|
+
if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
|
|
2840
|
+
const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
|
|
2841
|
+
if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
|
|
2842
|
+
const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
|
|
2843
|
+
if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
|
|
2844
|
+
}
|
|
2845
|
+
if (changed) for (const runtime of runtimes) runtime.invalidate();
|
|
2846
|
+
};
|
|
2210
2847
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2211
2848
|
for (const runtime of runtimes) {
|
|
2212
2849
|
registerQoderStatusRoute(webCtx, {
|
|
@@ -2351,82 +2988,65 @@ function apply(ctx, config) {
|
|
|
2351
2988
|
}
|
|
2352
2989
|
}, probeKey);
|
|
2353
2990
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
quota: () => config
|
|
2361
|
-
};
|
|
2362
|
-
/** Merge all sections into the whole config the rest of the plugin reads. */
|
|
2363
|
-
const merged = () => ({
|
|
2364
|
-
...pickFields(sources.cn, CN_SECTION_KEYS),
|
|
2365
|
-
...pickFields(sources.global, GLOBAL_SECTION_KEYS),
|
|
2366
|
-
...pickFields(sources.quota, QUOTA_SECTION_KEYS)
|
|
2367
|
-
});
|
|
2368
|
-
const applyCatalogSettings = (next) => {
|
|
2369
|
-
let changed = false;
|
|
2370
|
-
for (const runtime of runtimes) {
|
|
2371
|
-
const isChina = runtime.variant.id === CHINA_VARIANT.id;
|
|
2372
|
-
const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
|
|
2373
|
-
if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
|
|
2374
|
-
const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
|
|
2375
|
-
if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
|
|
2376
|
-
const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
|
|
2377
|
-
if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
|
|
2378
|
-
}
|
|
2379
|
-
if (changed) for (const runtime of runtimes) runtime.invalidate();
|
|
2380
|
-
};
|
|
2381
|
-
const repointStores = () => {
|
|
2382
|
-
applyCatalogSettings(merged());
|
|
2383
|
-
};
|
|
2384
|
-
settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, CHINA_SECTION, config, {
|
|
2385
|
-
setSource(source) {
|
|
2386
|
-
sources.cn = source;
|
|
2387
|
-
current = merged;
|
|
2388
|
-
},
|
|
2389
|
-
onChange: repointStores
|
|
2390
|
-
});
|
|
2391
|
-
settingsCtx.settings.installSection(ctx, QODER_GLOBAL_SETTINGS_NS, GLOBAL_SECTION, config, {
|
|
2392
|
-
setSource(source) {
|
|
2393
|
-
sources.global = source;
|
|
2394
|
-
current = merged;
|
|
2395
|
-
},
|
|
2396
|
-
onChange: repointStores
|
|
2397
|
-
});
|
|
2398
|
-
settingsCtx.settings.installSection(ctx, QODER_QUOTA_SETTINGS_NS, QUOTA_SECTION, config, {
|
|
2399
|
-
setSource(source) {
|
|
2400
|
-
sources.quota = source;
|
|
2401
|
-
current = merged;
|
|
2402
|
-
checkInScheduler.rearm();
|
|
2991
|
+
registerQoderSettingsFace(webCtx, {
|
|
2992
|
+
store,
|
|
2993
|
+
config,
|
|
2994
|
+
current,
|
|
2995
|
+
apply: applyCatalogSettings,
|
|
2996
|
+
rearm: () => {
|
|
2403
2997
|
runStartupCatchUpOnce();
|
|
2404
|
-
},
|
|
2405
|
-
onChange: () => {
|
|
2406
2998
|
checkInScheduler.rearm();
|
|
2407
|
-
checkInScheduler.sweepAll(true);
|
|
2408
2999
|
}
|
|
2409
3000
|
});
|
|
3001
|
+
});
|
|
3002
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
3003
|
+
/**
|
|
3004
|
+
* `auto: false` turns OFF the host's generated form page for this entry —
|
|
3005
|
+
* the plugin ships its own card, which is now the only editor. It must be
|
|
3006
|
+
* called as a METHOD: on 0.1.7 `configure` is a SettingsForms class method
|
|
3007
|
+
* (it reads `this.presentations`), and extracting it as a free function
|
|
3008
|
+
* throws a TypeError that used to kill the whole settings callback.
|
|
3009
|
+
*/
|
|
3010
|
+
const settingsService = settingsCtx.settings;
|
|
3011
|
+
if (typeof settingsService.configure === "function") try {
|
|
3012
|
+
settingsCtx.effect(() => {
|
|
3013
|
+
const dispose = settingsService.configure({ auto: false }, ctx.fiber);
|
|
3014
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
3015
|
+
});
|
|
3016
|
+
} catch (error) {
|
|
3017
|
+
console.error("[dsh-qoder-connect] settings.configure failed (own settings file still serves):", error);
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* The four setters, now one implementation on both hosts: write the
|
|
3021
|
+
* plugin-owned settings file, then apply the new view in memory. No
|
|
3022
|
+
* profile-patch write means no tree reconcile, no fiber reload, and no
|
|
3023
|
+
* client-mirror storm per toggle.
|
|
3024
|
+
*/
|
|
3025
|
+
const write = (patch) => {
|
|
3026
|
+
store.patch(patch, true);
|
|
3027
|
+
applyCatalogSettings(current());
|
|
3028
|
+
checkInScheduler.rearm();
|
|
3029
|
+
};
|
|
2410
3030
|
setMaximumContextWindow = async (enabled) => {
|
|
2411
|
-
|
|
3031
|
+
write({ useMaximumContextWindow: enabled });
|
|
2412
3032
|
return { state: "updated" };
|
|
2413
3033
|
};
|
|
2414
3034
|
setMaximumContextWindowCN = async (enabled) => {
|
|
2415
|
-
|
|
3035
|
+
write({ useMaximumContextWindowCN: enabled });
|
|
2416
3036
|
return { state: "updated" };
|
|
2417
3037
|
};
|
|
2418
3038
|
setModelsEnabled = async ({ models, enabled }) => {
|
|
2419
|
-
const currentDisabled = new Set(
|
|
3039
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModels") ?? []);
|
|
2420
3040
|
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
2421
3041
|
else currentDisabled.add(m);
|
|
2422
|
-
|
|
3042
|
+
write({ disabledModels: Array.from(currentDisabled) });
|
|
2423
3043
|
return { state: "updated" };
|
|
2424
3044
|
};
|
|
2425
3045
|
setModelsEnabledCN = async ({ models, enabled }) => {
|
|
2426
|
-
const currentDisabled = new Set(
|
|
3046
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModelsCN") ?? []);
|
|
2427
3047
|
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
2428
3048
|
else currentDisabled.add(m);
|
|
2429
|
-
|
|
3049
|
+
write({ disabledModelsCN: Array.from(currentDisabled) });
|
|
2430
3050
|
return { state: "updated" };
|
|
2431
3051
|
};
|
|
2432
3052
|
});
|
|
@@ -2492,6 +3112,7 @@ function apply(ctx, config) {
|
|
|
2492
3112
|
}
|
|
2493
3113
|
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
2494
3114
|
runtime.lastFetchAtMs = Date.now();
|
|
3115
|
+
lastLiveFetchAt.set(identity, runtime.lastFetchAtMs);
|
|
2495
3116
|
runtime.catalog.set([...models]);
|
|
2496
3117
|
runtime.catalogSource = "live";
|
|
2497
3118
|
runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
|
|
@@ -2544,7 +3165,8 @@ function apply(ctx, config) {
|
|
|
2544
3165
|
const identity = credentialIdentity(credential);
|
|
2545
3166
|
if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
|
|
2546
3167
|
const stale = runtime.catalogSource !== "live";
|
|
2547
|
-
const
|
|
3168
|
+
const fetchedAt = lastLiveFetchAt.get(identity) ?? 0;
|
|
3169
|
+
const due = Date.now() - fetchedAt >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
|
|
2548
3170
|
if (stale && due) await fetchCatalog(runtime, identity);
|
|
2549
3171
|
return;
|
|
2550
3172
|
}
|
|
@@ -2562,7 +3184,18 @@ function apply(ctx, config) {
|
|
|
2562
3184
|
* would be an unhandled rejection — which Node turns into process
|
|
2563
3185
|
* termination, taking the whole Harness down over one plugin's startup.
|
|
2564
3186
|
*/
|
|
2565
|
-
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime
|
|
3187
|
+
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime, async () => {
|
|
3188
|
+
if (stopped) return;
|
|
3189
|
+
let credential;
|
|
3190
|
+
try {
|
|
3191
|
+
credential = await runtime.store.current();
|
|
3192
|
+
} catch {
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (credential === void 0) return;
|
|
3196
|
+
const identity = credentialIdentity(credential);
|
|
3197
|
+
if (lastIdentities.get(runtime.variant.id) !== identity || !runtime.catalog.isVisible()) adoptIdentity(runtime, identity);
|
|
3198
|
+
}))).then(() => {
|
|
2566
3199
|
if (stopped) return;
|
|
2567
3200
|
if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
|
|
2568
3201
|
detach(ctx, syncAll(), "credential sweep");
|