dsh-qoder-connect 0.1.8 → 0.2.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/assets/icon.svg +6 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +247 -22
- package/lib/index.d.ts +6 -0
- package/lib/index.js +759 -119
- package/lib/{variants-DE76gF0Y.js → variants-DuEgaUad.js} +183 -61
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +11 -4
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-DuEgaUad.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
|
}
|
|
@@ -614,7 +628,7 @@ function createQoderShim(options) {
|
|
|
614
628
|
req.on("close", () => controller.abort());
|
|
615
629
|
const result = await client.chatStream(raw, controller.signal);
|
|
616
630
|
if (!result.ok) {
|
|
617
|
-
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${result.
|
|
631
|
+
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`);
|
|
618
632
|
return;
|
|
619
633
|
}
|
|
620
634
|
res.writeHead(200, {
|
|
@@ -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. */
|
|
@@ -1854,9 +2113,16 @@ function jobTokenHintText(at) {
|
|
|
1854
2113
|
* fresh token is not necessarily an authorization problem at all, and the real
|
|
1855
2114
|
* reason travels in the failure message itself (see the SSE envelope body).
|
|
1856
2115
|
* Claiming "quota" or "revoked" here would be a guess presented as a finding.
|
|
2116
|
+
*
|
|
2117
|
+
* It also must not point the reader at "the error above": the failed heal does
|
|
2118
|
+
* not always belong to a chat whose failure renders a card in this
|
|
2119
|
+
* conversation. A heal inside the session-title request — same transport,
|
|
2120
|
+
* same shared notice state — fails silently there, so the row used to send
|
|
2121
|
+
* users looking for an error card that does not exist. The row states only
|
|
2122
|
+
* what is known: the retry ran, it did not recover, the request failed.
|
|
1857
2123
|
*/
|
|
1858
2124
|
function jobTokenRefreshFailedHintText(at) {
|
|
1859
|
-
return `jobToken 已重换但仍被上游拒绝(${new Date(at).toLocaleTimeString("zh-CN", { hour12: false })})—
|
|
2125
|
+
return `jobToken 已重换但仍被上游拒绝(${new Date(at).toLocaleTimeString("zh-CN", { hour12: false })})— 自愈未恢复,该请求已失败`;
|
|
1860
2126
|
}
|
|
1861
2127
|
/** Build one variant's stores, transport, and probe state. */
|
|
1862
2128
|
function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
@@ -1901,13 +2167,14 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
|
1901
2167
|
});
|
|
1902
2168
|
const fallback = fallbackFor(variant);
|
|
1903
2169
|
const catalog = new QoderCatalog(fallback);
|
|
2170
|
+
const initial = current();
|
|
1904
2171
|
if (variant.id !== CHINA_VARIANT.id) {
|
|
1905
|
-
catalog.setUseMaximumContextWindow(
|
|
1906
|
-
if (
|
|
2172
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindow === true);
|
|
2173
|
+
if (initial.disabledModels !== void 0) catalog.setDisabledModels(initial.disabledModels);
|
|
1907
2174
|
} else {
|
|
1908
|
-
catalog.setUseMaximumContextWindow(
|
|
1909
|
-
if (
|
|
1910
|
-
if (
|
|
2175
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindowCN === true);
|
|
2176
|
+
if (initial.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(initial.modelContextWindowsCN);
|
|
2177
|
+
if (initial.disabledModelsCN !== void 0) catalog.setDisabledModels(initial.disabledModelsCN);
|
|
1911
2178
|
}
|
|
1912
2179
|
catalog.setVisible(false);
|
|
1913
2180
|
const probeStore = new QoderProbeStore({
|
|
@@ -2010,9 +2277,11 @@ function detach(ctx, work, what) {
|
|
|
2010
2277
|
* models read the shim origin at construction time. A failure here is
|
|
2011
2278
|
* contained to this variant: the caller logs it and the other keeps working.
|
|
2012
2279
|
*
|
|
2280
|
+
* @param seedCatalog - adopt the current credential into this runtime's
|
|
2281
|
+
* catalog immediately; supplied by `apply` (it owns `adoptIdentity`).
|
|
2013
2282
|
* @returns whether the provider registered.
|
|
2014
2283
|
*/
|
|
2015
|
-
async function startVariant(ctx, runtime) {
|
|
2284
|
+
async function startVariant(ctx, runtime, seedCatalog) {
|
|
2016
2285
|
const { variant, store, client, catalog, probeService } = runtime;
|
|
2017
2286
|
const shim = createQoderShim({
|
|
2018
2287
|
store,
|
|
@@ -2045,10 +2314,19 @@ async function startVariant(ctx, runtime) {
|
|
|
2045
2314
|
let releaseDirectory;
|
|
2046
2315
|
try {
|
|
2047
2316
|
releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
|
|
2317
|
+
const host017 = (() => {
|
|
2318
|
+
try {
|
|
2319
|
+
return ctx.get?.("configEditor") !== void 0;
|
|
2320
|
+
} catch {
|
|
2321
|
+
return false;
|
|
2322
|
+
}
|
|
2323
|
+
})();
|
|
2324
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
2325
|
+
const settingsNs = host017 && entryId !== void 0 ? entryId : settingsNamespaceFor(variant);
|
|
2048
2326
|
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
2049
2327
|
provider: variant.id,
|
|
2050
2328
|
displayName: variant.displayName,
|
|
2051
|
-
settingsNs
|
|
2329
|
+
settingsNs,
|
|
2052
2330
|
settingsPath: [],
|
|
2053
2331
|
declared: false
|
|
2054
2332
|
}]);
|
|
@@ -2070,6 +2348,7 @@ async function startVariant(ctx, runtime) {
|
|
|
2070
2348
|
detach(ctx, shim.close(), "loopback endpoint close");
|
|
2071
2349
|
}
|
|
2072
2350
|
runtime.registered = true;
|
|
2351
|
+
detach(ctx, seedCatalog(), `${variant.id} catalog seed`);
|
|
2073
2352
|
return true;
|
|
2074
2353
|
} catch (error) {
|
|
2075
2354
|
ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
|
|
@@ -2078,6 +2357,305 @@ async function startVariant(ctx, runtime) {
|
|
|
2078
2357
|
}
|
|
2079
2358
|
}
|
|
2080
2359
|
/**
|
|
2360
|
+
* State that MUST survive a fiber reload, module-level on purpose.
|
|
2361
|
+
*
|
|
2362
|
+
* DSH 0.1.7's configuration write (`configEditor.edit`) reconciles the profile
|
|
2363
|
+
* tree, which hot-reloads the entry's fiber — `apply()` runs again with a fresh
|
|
2364
|
+
* closure. Anything re-minted per apply is therefore invalidated by every
|
|
2365
|
+
* settings write: the browser cards hold the keys the status document handed
|
|
2366
|
+
* them, so a per-apply key turns each write into a wave of 403s ("刷新失败"),
|
|
2367
|
+
* and a per-apply identity map makes the sweep re-fetch the catalog from
|
|
2368
|
+
* upstream on every write (the burst of requests and most of the latency).
|
|
2369
|
+
*
|
|
2370
|
+
* Keys and identity/fetch bookkeeping are per-PROCESS secrets and caches, not
|
|
2371
|
+
* per-instance state, so they live here once. A reload keeps the same keys (no
|
|
2372
|
+
* 403 storm) and a same-account, freshly-fetched sweep (no refetch burst).
|
|
2373
|
+
*/
|
|
2374
|
+
/** The in-process control keys, minted once per process. */
|
|
2375
|
+
let processKeys;
|
|
2376
|
+
function controlKeys() {
|
|
2377
|
+
processKeys ??= {
|
|
2378
|
+
probe: createProbeKey(),
|
|
2379
|
+
auth: createAuthKey()
|
|
2380
|
+
};
|
|
2381
|
+
return processKeys;
|
|
2382
|
+
}
|
|
2383
|
+
/** The loopback guard the probe and status routes use, restated for settings. */
|
|
2384
|
+
function trustedSettingsRequest(req) {
|
|
2385
|
+
const host = req.headers.host ?? "";
|
|
2386
|
+
const origin = req.headers.origin;
|
|
2387
|
+
if (!/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(host))) return false;
|
|
2388
|
+
return origin === void 0 || /^https?:\/\/(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(origin));
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* The schema defaults, READ off each field's own `meta.default`.
|
|
2392
|
+
*
|
|
2393
|
+
* Two ways this was got wrong before, both silent and both destructive: a
|
|
2394
|
+
* hand-written table drifted from the schema (so the schema's own defaults
|
|
2395
|
+
* were classified as real overrides and the legacy settings document could
|
|
2396
|
+
* never win), and `validate({})` was then tried instead — which on this
|
|
2397
|
+
* schemastery answers with hollow `{}` per field instead of applying the
|
|
2398
|
+
* defaults, which is worse than nothing because it looks like it worked.
|
|
2399
|
+
* Walking `Config.dict` for `meta.default` is the only honest source: it is
|
|
2400
|
+
* the value schemastery itself substituted during validation.
|
|
2401
|
+
*/
|
|
2402
|
+
const DEFAULT_FOR_FIELD = (() => {
|
|
2403
|
+
const out = {};
|
|
2404
|
+
try {
|
|
2405
|
+
for (const [key, field] of Object.entries(Config.dict ?? {})) {
|
|
2406
|
+
const fallback = field?.meta?.default;
|
|
2407
|
+
if (fallback !== void 0) out[key] = fallback;
|
|
2408
|
+
}
|
|
2409
|
+
} catch {}
|
|
2410
|
+
if (Object.keys(out).length === 0) return {
|
|
2411
|
+
probeConsent: false,
|
|
2412
|
+
useMaximumContextWindow: true,
|
|
2413
|
+
useMaximumContextWindowCN: true,
|
|
2414
|
+
modelContextWindows: {},
|
|
2415
|
+
modelContextWindowsCN: {},
|
|
2416
|
+
disabledModels: [],
|
|
2417
|
+
disabledModelsCN: [],
|
|
2418
|
+
sidebarQuotaCN: false,
|
|
2419
|
+
sidebarQuotaGlobal: false,
|
|
2420
|
+
autoCheckInCN: false,
|
|
2421
|
+
autoCheckInGlobal: false,
|
|
2422
|
+
checkInMinuteCN: 600,
|
|
2423
|
+
checkInMinuteGlobal: 600,
|
|
2424
|
+
quotaPollMs: 3e5
|
|
2425
|
+
};
|
|
2426
|
+
return out;
|
|
2427
|
+
})();
|
|
2428
|
+
/**
|
|
2429
|
+
* Register the settings face (GET/POST) the browser cards read and write
|
|
2430
|
+
* through, answering the whole entry configuration as three layers.
|
|
2431
|
+
*
|
|
2432
|
+
* `value` carries every declared field, so a card rendering the quota section
|
|
2433
|
+
* sees the same merged view the host itself reads; `user` is the settings file
|
|
2434
|
+
* exactly as stored (presence marks an override), which is what the staged-edit
|
|
2435
|
+
* form's "written/reset" display needs. A POST validates the fields it is
|
|
2436
|
+
* allowed to touch, writes the file, applies the new view in memory, and
|
|
2437
|
+
* re-arms the check-in scheduler (the old 0.1.5 section `onChange` behaviour).
|
|
2438
|
+
*/
|
|
2439
|
+
function registerQoderSettingsFace(ctx, deps) {
|
|
2440
|
+
const { store, config, current, apply, rearm } = deps;
|
|
2441
|
+
const key = controlKeys().probe;
|
|
2442
|
+
/** The document both routes answer with. */
|
|
2443
|
+
const view = () => {
|
|
2444
|
+
const merged = current();
|
|
2445
|
+
const value = {};
|
|
2446
|
+
const base = {};
|
|
2447
|
+
for (const field of CONFIG_KEYS) {
|
|
2448
|
+
if (merged[field] !== void 0) value[field] = merged[field];
|
|
2449
|
+
base[field] = readConfig(config)[field] ?? DEFAULT_FOR_FIELD[field];
|
|
2450
|
+
}
|
|
2451
|
+
return {
|
|
2452
|
+
key,
|
|
2453
|
+
value,
|
|
2454
|
+
base,
|
|
2455
|
+
user: store.values()
|
|
2456
|
+
};
|
|
2457
|
+
};
|
|
2458
|
+
ctx.effect(() => {
|
|
2459
|
+
const dispose = ctx.webServer.register({
|
|
2460
|
+
kind: "exact",
|
|
2461
|
+
path: QODER_SETTINGS_FACE_PATH,
|
|
2462
|
+
handler: async (req, res) => {
|
|
2463
|
+
if (!trustedSettingsRequest(req)) {
|
|
2464
|
+
jsonFace(res, 403, { error: "request-not-trusted" });
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
if (req.method === "GET") {
|
|
2468
|
+
jsonFace(res, 200, view());
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (req.method !== "POST") {
|
|
2472
|
+
jsonFace(res, 405, { error: "method not allowed" });
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
if (req.headers["x-qoder-settings-key"] !== key) {
|
|
2476
|
+
jsonFace(res, 403, { error: "invalid-key" });
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
const body = await readFaceBody(req);
|
|
2480
|
+
if (body === void 0) {
|
|
2481
|
+
jsonFace(res, 413, { error: "body too large" });
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
let patch;
|
|
2485
|
+
try {
|
|
2486
|
+
patch = JSON.parse(body || "{}");
|
|
2487
|
+
} catch {
|
|
2488
|
+
jsonFace(res, 400, { error: "invalid json" });
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
|
|
2492
|
+
jsonFace(res, 400, { error: "invalid patch" });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
const invalid = validateSettingsPatch(patch);
|
|
2496
|
+
if (invalid !== void 0) {
|
|
2497
|
+
jsonFace(res, 400, { error: invalid });
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
store.patch(patch, true);
|
|
2501
|
+
apply(current());
|
|
2502
|
+
rearm();
|
|
2503
|
+
jsonFace(res, 200, view());
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
return () => {
|
|
2507
|
+
dispose();
|
|
2508
|
+
};
|
|
2509
|
+
}, "dsh-qoder-connect: settings face");
|
|
2510
|
+
}
|
|
2511
|
+
/** JSON response helper for the settings face. */
|
|
2512
|
+
function jsonFace(res, status, body) {
|
|
2513
|
+
const payload = JSON.stringify(body);
|
|
2514
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2515
|
+
res.end(payload);
|
|
2516
|
+
}
|
|
2517
|
+
/** Read the request body, or undefined when absent or oversized. */
|
|
2518
|
+
function readFaceBody(req) {
|
|
2519
|
+
return new Promise((resolve) => {
|
|
2520
|
+
let body = "";
|
|
2521
|
+
req.on("data", (chunk) => {
|
|
2522
|
+
body += String(chunk);
|
|
2523
|
+
if (body.length > 1e6) resolve(void 0);
|
|
2524
|
+
});
|
|
2525
|
+
req.on("end", () => resolve(body));
|
|
2526
|
+
req.on("error", () => resolve(void 0));
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
/** Schema-level validation of one settings patch; the reason, or undefined. */
|
|
2530
|
+
function validateSettingsPatch(patch) {
|
|
2531
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
2532
|
+
if (!CONFIG_KEYS.includes(field)) return `unknown field ${field}`;
|
|
2533
|
+
if (value === null) continue;
|
|
2534
|
+
switch (field) {
|
|
2535
|
+
case "probeConsent":
|
|
2536
|
+
case "useMaximumContextWindow":
|
|
2537
|
+
case "useMaximumContextWindowCN":
|
|
2538
|
+
case "sidebarQuotaCN":
|
|
2539
|
+
case "sidebarQuotaGlobal":
|
|
2540
|
+
case "autoCheckInCN":
|
|
2541
|
+
case "autoCheckInGlobal":
|
|
2542
|
+
if (typeof value !== "boolean") return `${field} must be a boolean`;
|
|
2543
|
+
break;
|
|
2544
|
+
case "disabledModels":
|
|
2545
|
+
case "disabledModelsCN":
|
|
2546
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${field} must be an array of strings`;
|
|
2547
|
+
break;
|
|
2548
|
+
case "checkInMinuteCN":
|
|
2549
|
+
case "checkInMinuteGlobal":
|
|
2550
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 1439) return `${field} must be an integer minute 0..1439`;
|
|
2551
|
+
break;
|
|
2552
|
+
case "quotaPollMs":
|
|
2553
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 6e4) return `${field} must be an integer of at least 60000 ms`;
|
|
2554
|
+
break;
|
|
2555
|
+
case "modelContextWindows":
|
|
2556
|
+
case "modelContextWindowsCN": if (typeof value !== "object" || value === null || Array.isArray(value)) return `${field} must be an object`;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* When the profile tree last recomposed, module-level so every apply sees it.
|
|
2562
|
+
*
|
|
2563
|
+
* The legacy settings.yaml import writes the profile patch one section at a
|
|
2564
|
+
* time for seconds after the Loader settles, emitting this event on every
|
|
2565
|
+
* write. Cleaning up our row in that window is futile — the import's next
|
|
2566
|
+
* section simply writes it back — so the cleanup waits for the tree to fall
|
|
2567
|
+
* quiet first.
|
|
2568
|
+
*/
|
|
2569
|
+
let lastConfigReloadAt = Date.now();
|
|
2570
|
+
/** Wait until the profile tree has been quiet (the legacy import finished). */
|
|
2571
|
+
async function waitForProfileQuiet() {
|
|
2572
|
+
const deadline = Date.now() + 12e4;
|
|
2573
|
+
for (;;) {
|
|
2574
|
+
if (Date.now() - lastConfigReloadAt >= 5e3) return;
|
|
2575
|
+
if (Date.now() >= deadline) return;
|
|
2576
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Delete this plugin's own fields from the profile entry config, leaving every
|
|
2581
|
+
* other key of the row untouched.
|
|
2582
|
+
*
|
|
2583
|
+
* One-time, right after the settings file has been seeded: the entry returns to
|
|
2584
|
+
* its shipped state, so no second source of truth remains. Both write APIs are
|
|
2585
|
+
* probed — `configEditor` (0.1.7) first, then the settings service's namespace
|
|
2586
|
+
* `replace` (0.1.5), which rebuilds each of this plugin's three sections from
|
|
2587
|
+
* the foreign keys alone.
|
|
2588
|
+
*
|
|
2589
|
+
* @param ctx - plugin context.
|
|
2590
|
+
* @param ownKeys - this plugin's declared config fields.
|
|
2591
|
+
* @param namespaces - the 0.1.5 section namespaces this plugin owns.
|
|
2592
|
+
*/
|
|
2593
|
+
async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
|
|
2594
|
+
const attempts = 10;
|
|
2595
|
+
for (let attempt = 0; attempt < attempts; attempt++) try {
|
|
2596
|
+
if (attempt === 0) await waitForProfileQuiet();
|
|
2597
|
+
else await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
|
|
2598
|
+
const probe = ctx;
|
|
2599
|
+
const editor = (typeof probe.get === "function" ? (() => {
|
|
2600
|
+
try {
|
|
2601
|
+
return probe.get.call(ctx, "configEditor");
|
|
2602
|
+
} catch {
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
})() : void 0) ?? await new Promise((resolve) => {
|
|
2606
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2607
|
+
const owner = settingsCtx.settings?.ownerContext;
|
|
2608
|
+
resolve(typeof owner?.get === "function" ? (() => {
|
|
2609
|
+
try {
|
|
2610
|
+
return owner.get.call(owner, "configEditor");
|
|
2611
|
+
} catch {
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
})() : void 0);
|
|
2615
|
+
});
|
|
2616
|
+
});
|
|
2617
|
+
const entry = probe.fiber?.entry;
|
|
2618
|
+
if (editor !== void 0 && entry !== void 0) {
|
|
2619
|
+
await editor.edit(entry, (raw, inherited) => {
|
|
2620
|
+
const next = { ...inherited ?? {} };
|
|
2621
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
2622
|
+
if (ownKeys.includes(key)) continue;
|
|
2623
|
+
if (!Object.hasOwn(next, key)) next[key] = value;
|
|
2624
|
+
}
|
|
2625
|
+
return next;
|
|
2626
|
+
});
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
await new Promise((resolve) => {
|
|
2630
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2631
|
+
const settings = settingsCtx.settings;
|
|
2632
|
+
if (typeof settings?.replace !== "function" || typeof settings.installSection !== "function") {
|
|
2633
|
+
resolve(void 0);
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
Promise.all(namespaces.map((ns) => Promise.resolve(settings.replace(ns, {})).catch(() => void 0))).then(() => resolve(void 0));
|
|
2637
|
+
});
|
|
2638
|
+
});
|
|
2639
|
+
return;
|
|
2640
|
+
} catch (error) {
|
|
2641
|
+
if (attempt === 9) {
|
|
2642
|
+
ctx.logger?.warn?.("dsh-qoder-connect: entry config cleanup failed", error);
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1) + Math.random() * 500));
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
/** The account identity each variant last published a catalog for, across reloads. */
|
|
2649
|
+
const lastIdentities = /* @__PURE__ */ new Map();
|
|
2650
|
+
/**
|
|
2651
|
+
* When a variant's catalog was last fetched LIVE from upstream, per identity.
|
|
2652
|
+
*
|
|
2653
|
+
* A reload reseeds the catalog from the saved file; without this record the
|
|
2654
|
+
* sweep's retry rule (`stale && due`) sees `lastFetchAtMs = 0` and re-fetches
|
|
2655
|
+
* immediately — one upstream round trip per variant per settings write.
|
|
2656
|
+
*/
|
|
2657
|
+
const lastLiveFetchAt = /* @__PURE__ */ new Map();
|
|
2658
|
+
/**
|
|
2081
2659
|
* Start both variants: their loopback endpoints, the `qoder` and
|
|
2082
2660
|
* `qoder-global` providers, their configuration cards, and their
|
|
2083
2661
|
* credential-driven catalog lifecycles.
|
|
@@ -2088,16 +2666,72 @@ async function startVariant(ctx, runtime) {
|
|
|
2088
2666
|
* without re-registering the provider.
|
|
2089
2667
|
*/
|
|
2090
2668
|
function apply(ctx, config) {
|
|
2091
|
-
|
|
2669
|
+
const store = new SettingsStore();
|
|
2670
|
+
let current = () => ({
|
|
2671
|
+
...readConfig(config),
|
|
2672
|
+
...store.values()
|
|
2673
|
+
});
|
|
2674
|
+
/**
|
|
2675
|
+
* One-time migration: per field — the file never held it → take the entry;
|
|
2676
|
+
* the card HAS written this file → keep the file; the entry carries a
|
|
2677
|
+
* NON-DEFAULT value → take the entry (the legacy settings.yaml import lands
|
|
2678
|
+
* only after the Loader settles, i.e. after this plugin's first apply, and a
|
|
2679
|
+
* seed taken inside that window can hold a stale or mis-encoded value); the
|
|
2680
|
+
* entry only carries the schema default → keep the file.
|
|
2681
|
+
*
|
|
2682
|
+
* That last clause is load-bearing: a bundle layer's insert config does NOT
|
|
2683
|
+
* reach the composition (verified with `dsh --dump-config`), so once the
|
|
2684
|
+
* one-time cleanup has emptied the entry row the entry answers pure defaults —
|
|
2685
|
+
* treating those as authoritative would erase the user's values on the next
|
|
2686
|
+
* boot. Re-checked on every apply, which is also what closes the legacy
|
|
2687
|
+
* import's timing window.
|
|
2688
|
+
*
|
|
2689
|
+
* When the entry is authoritative, its own fields are then deleted from the
|
|
2690
|
+
* profile row, so no second source of truth remains.
|
|
2691
|
+
*/
|
|
2692
|
+
const migrateOwnSettings = () => {
|
|
2693
|
+
const legacy = readLegacySections([
|
|
2694
|
+
QODER_SETTINGS_NS,
|
|
2695
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2696
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2697
|
+
]);
|
|
2698
|
+
const seeded = {};
|
|
2699
|
+
for (const key of CONFIG_KEYS) {
|
|
2700
|
+
const entryValue = readField(config, key);
|
|
2701
|
+
const legacyValue = legacy === void 0 ? void 0 : legacy[key];
|
|
2702
|
+
if (!Object.hasOwn(store.user, key)) {
|
|
2703
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
|
|
2704
|
+
else if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2705
|
+
else if (entryValue !== void 0) seeded[key] = entryValue;
|
|
2706
|
+
else if (legacyValue !== void 0) seeded[key] = legacyValue;
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
if (store.edited) continue;
|
|
2710
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) {
|
|
2711
|
+
seeded[key] = entryValue;
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2715
|
+
}
|
|
2716
|
+
if (Object.keys(seeded).length === 0) return;
|
|
2717
|
+
store.patch(seeded);
|
|
2718
|
+
cleanupEntryConfig(ctx, CONFIG_KEYS, [
|
|
2719
|
+
QODER_SETTINGS_NS,
|
|
2720
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2721
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2722
|
+
]);
|
|
2723
|
+
};
|
|
2724
|
+
migrateOwnSettings();
|
|
2725
|
+
const eventSink = ctx;
|
|
2726
|
+
eventSink.on("loader/volatile-update", () => {
|
|
2727
|
+
migrateOwnSettings();
|
|
2728
|
+
});
|
|
2729
|
+
eventSink.on("app-boot/config-reload", () => {
|
|
2730
|
+
lastConfigReloadAt = Date.now();
|
|
2731
|
+
});
|
|
2092
2732
|
/** Timers and in-flight work belonging to this plugin instance. */
|
|
2093
2733
|
let stopped = false;
|
|
2094
2734
|
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
2735
|
const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
|
|
2102
2736
|
const checkInStore = new JsonFileCheckInStore();
|
|
2103
2737
|
const checkInScheduler = new CheckInScheduler({
|
|
@@ -2135,17 +2769,7 @@ function apply(ctx, config) {
|
|
|
2135
2769
|
checkInScheduler.catchUp();
|
|
2136
2770
|
};
|
|
2137
2771
|
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();
|
|
2772
|
+
const { probe: probeKey, auth: authKey } = controlKeys();
|
|
2149
2773
|
let setMaximumContextWindow;
|
|
2150
2774
|
let setMaximumContextWindowCN;
|
|
2151
2775
|
let setModelsEnabled;
|
|
@@ -2173,7 +2797,7 @@ function apply(ctx, config) {
|
|
|
2173
2797
|
const adoptIdentity = (runtime, identity) => {
|
|
2174
2798
|
const id = runtime.variant.id;
|
|
2175
2799
|
const known = lastIdentities.get(id);
|
|
2176
|
-
if (known === identity) return;
|
|
2800
|
+
if (known === identity && runtime.catalog.isVisible()) return;
|
|
2177
2801
|
const hadCredential = known !== void 0;
|
|
2178
2802
|
if (identity === void 0) lastIdentities.delete(id);
|
|
2179
2803
|
else lastIdentities.set(id, identity);
|
|
@@ -2207,6 +2831,26 @@ function apply(ctx, config) {
|
|
|
2207
2831
|
runtime.catalog.setVisible(true);
|
|
2208
2832
|
runtime.invalidate();
|
|
2209
2833
|
};
|
|
2834
|
+
/**
|
|
2835
|
+
* Apply catalog preferences from a configuration view, in memory.
|
|
2836
|
+
*
|
|
2837
|
+
* Shared by the probe-route setters and the settings face's POST: both write
|
|
2838
|
+
* the plugin-owned settings file and then land the new view here, which is
|
|
2839
|
+
* what the cards see on their next status read.
|
|
2840
|
+
*/
|
|
2841
|
+
const applyCatalogSettings = (next) => {
|
|
2842
|
+
let changed = false;
|
|
2843
|
+
for (const runtime of runtimes) {
|
|
2844
|
+
const isChina = runtime.variant.id === CHINA_VARIANT.id;
|
|
2845
|
+
const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
|
|
2846
|
+
if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
|
|
2847
|
+
const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
|
|
2848
|
+
if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
|
|
2849
|
+
const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
|
|
2850
|
+
if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
|
|
2851
|
+
}
|
|
2852
|
+
if (changed) for (const runtime of runtimes) runtime.invalidate();
|
|
2853
|
+
};
|
|
2210
2854
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2211
2855
|
for (const runtime of runtimes) {
|
|
2212
2856
|
registerQoderStatusRoute(webCtx, {
|
|
@@ -2351,82 +2995,65 @@ function apply(ctx, config) {
|
|
|
2351
2995
|
}
|
|
2352
2996
|
}, probeKey);
|
|
2353
2997
|
}
|
|
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();
|
|
2998
|
+
registerQoderSettingsFace(webCtx, {
|
|
2999
|
+
store,
|
|
3000
|
+
config,
|
|
3001
|
+
current,
|
|
3002
|
+
apply: applyCatalogSettings,
|
|
3003
|
+
rearm: () => {
|
|
2403
3004
|
runStartupCatchUpOnce();
|
|
2404
|
-
},
|
|
2405
|
-
onChange: () => {
|
|
2406
3005
|
checkInScheduler.rearm();
|
|
2407
|
-
checkInScheduler.sweepAll(true);
|
|
2408
3006
|
}
|
|
2409
3007
|
});
|
|
3008
|
+
});
|
|
3009
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
3010
|
+
/**
|
|
3011
|
+
* `auto: false` turns OFF the host's generated form page for this entry —
|
|
3012
|
+
* the plugin ships its own card, which is now the only editor. It must be
|
|
3013
|
+
* called as a METHOD: on 0.1.7 `configure` is a SettingsForms class method
|
|
3014
|
+
* (it reads `this.presentations`), and extracting it as a free function
|
|
3015
|
+
* throws a TypeError that used to kill the whole settings callback.
|
|
3016
|
+
*/
|
|
3017
|
+
const settingsService = settingsCtx.settings;
|
|
3018
|
+
if (typeof settingsService.configure === "function") try {
|
|
3019
|
+
settingsCtx.effect(() => {
|
|
3020
|
+
const dispose = settingsService.configure({ auto: false }, ctx.fiber);
|
|
3021
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
3022
|
+
});
|
|
3023
|
+
} catch (error) {
|
|
3024
|
+
console.error("[dsh-qoder-connect] settings.configure failed (own settings file still serves):", error);
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* The four setters, now one implementation on both hosts: write the
|
|
3028
|
+
* plugin-owned settings file, then apply the new view in memory. No
|
|
3029
|
+
* profile-patch write means no tree reconcile, no fiber reload, and no
|
|
3030
|
+
* client-mirror storm per toggle.
|
|
3031
|
+
*/
|
|
3032
|
+
const write = (patch) => {
|
|
3033
|
+
store.patch(patch, true);
|
|
3034
|
+
applyCatalogSettings(current());
|
|
3035
|
+
checkInScheduler.rearm();
|
|
3036
|
+
};
|
|
2410
3037
|
setMaximumContextWindow = async (enabled) => {
|
|
2411
|
-
|
|
3038
|
+
write({ useMaximumContextWindow: enabled });
|
|
2412
3039
|
return { state: "updated" };
|
|
2413
3040
|
};
|
|
2414
3041
|
setMaximumContextWindowCN = async (enabled) => {
|
|
2415
|
-
|
|
3042
|
+
write({ useMaximumContextWindowCN: enabled });
|
|
2416
3043
|
return { state: "updated" };
|
|
2417
3044
|
};
|
|
2418
3045
|
setModelsEnabled = async ({ models, enabled }) => {
|
|
2419
|
-
const currentDisabled = new Set(
|
|
3046
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModels") ?? []);
|
|
2420
3047
|
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
2421
3048
|
else currentDisabled.add(m);
|
|
2422
|
-
|
|
3049
|
+
write({ disabledModels: Array.from(currentDisabled) });
|
|
2423
3050
|
return { state: "updated" };
|
|
2424
3051
|
};
|
|
2425
3052
|
setModelsEnabledCN = async ({ models, enabled }) => {
|
|
2426
|
-
const currentDisabled = new Set(
|
|
3053
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModelsCN") ?? []);
|
|
2427
3054
|
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
2428
3055
|
else currentDisabled.add(m);
|
|
2429
|
-
|
|
3056
|
+
write({ disabledModelsCN: Array.from(currentDisabled) });
|
|
2430
3057
|
return { state: "updated" };
|
|
2431
3058
|
};
|
|
2432
3059
|
});
|
|
@@ -2492,6 +3119,7 @@ function apply(ctx, config) {
|
|
|
2492
3119
|
}
|
|
2493
3120
|
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
2494
3121
|
runtime.lastFetchAtMs = Date.now();
|
|
3122
|
+
lastLiveFetchAt.set(identity, runtime.lastFetchAtMs);
|
|
2495
3123
|
runtime.catalog.set([...models]);
|
|
2496
3124
|
runtime.catalogSource = "live";
|
|
2497
3125
|
runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
|
|
@@ -2544,7 +3172,8 @@ function apply(ctx, config) {
|
|
|
2544
3172
|
const identity = credentialIdentity(credential);
|
|
2545
3173
|
if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
|
|
2546
3174
|
const stale = runtime.catalogSource !== "live";
|
|
2547
|
-
const
|
|
3175
|
+
const fetchedAt = lastLiveFetchAt.get(identity) ?? 0;
|
|
3176
|
+
const due = Date.now() - fetchedAt >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
|
|
2548
3177
|
if (stale && due) await fetchCatalog(runtime, identity);
|
|
2549
3178
|
return;
|
|
2550
3179
|
}
|
|
@@ -2562,7 +3191,18 @@ function apply(ctx, config) {
|
|
|
2562
3191
|
* would be an unhandled rejection — which Node turns into process
|
|
2563
3192
|
* termination, taking the whole Harness down over one plugin's startup.
|
|
2564
3193
|
*/
|
|
2565
|
-
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime
|
|
3194
|
+
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime, async () => {
|
|
3195
|
+
if (stopped) return;
|
|
3196
|
+
let credential;
|
|
3197
|
+
try {
|
|
3198
|
+
credential = await runtime.store.current();
|
|
3199
|
+
} catch {
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
if (credential === void 0) return;
|
|
3203
|
+
const identity = credentialIdentity(credential);
|
|
3204
|
+
if (lastIdentities.get(runtime.variant.id) !== identity || !runtime.catalog.isVisible()) adoptIdentity(runtime, identity);
|
|
3205
|
+
}))).then(() => {
|
|
2566
3206
|
if (stopped) return;
|
|
2567
3207
|
if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
|
|
2568
3208
|
detach(ctx, syncAll(), "credential sweep");
|