@worca/app 1.3.0 → 1.4.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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
package/src/core/settings.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// pipelineCostLimitUsd — per-pipeline lifetime USD spend cap; unset = no limit.
|
|
21
21
|
// totalCostLimitUsd — windowed all-pipelines USD spend cap; unset = no limit.
|
|
22
22
|
// costLimitResetPeriod — total-budget window, 'weekly' | 'monthly' (default).
|
|
23
|
+
// pythonPath — §7 of the scripts-workbench spec: the python
|
|
24
|
+
// interpreter the script-card probe tries after
|
|
25
|
+
// WORCA_PYTHON and before the platform defaults.
|
|
23
26
|
// models — the global model catalog (configurable-models-design.md
|
|
24
27
|
// §4.1): [{id, label?, efforts?, env?}]. Entries shadow
|
|
25
28
|
// PREDEFINED_MODELS by id; env is per-model routing env
|
|
@@ -48,7 +51,12 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
48
51
|
import { join, resolve } from 'node:path';
|
|
49
52
|
import { homedir } from 'node:os';
|
|
50
53
|
import { randomBytes } from 'node:crypto';
|
|
51
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
EFFORTS, isReservedModelEnvKey, assertModelCost, envFlag,
|
|
56
|
+
assertModelUpstream, upstreamEnvConflict, modelEnvRef,
|
|
57
|
+
UPSTREAM_PROVIDERS, COPILOT_ACCOUNT_TYPES, DEFAULT_PROVIDER_CONCURRENCY, MAX_PROVIDER_CONCURRENCY,
|
|
58
|
+
COPILOT_TERMS_VERSION, isUpstreamBaseUrl,
|
|
59
|
+
} from './model-env.mjs';
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
62
|
* The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
|
|
@@ -338,6 +346,102 @@ export function memoryCaps() {
|
|
|
338
346
|
};
|
|
339
347
|
}
|
|
340
348
|
|
|
349
|
+
// ── Memory defragment model (Settings › Memory) ─────────────────────────────
|
|
350
|
+
// `memory.defrag.model` / `memory.defrag.effort`: the pair EVERY Memory defragment run uses
|
|
351
|
+
// (memory-defrag-model.mjs resolves it at run start; the setting is GLOBAL, there is no
|
|
352
|
+
// per-project variant). They share the `memory.defrag` block with the numeric health
|
|
353
|
+
// thresholds, but readDefragThreshold reads only its own four keys and memoryCaps() —
|
|
354
|
+
// which feeds every memory sync — never carries them.
|
|
355
|
+
const DEFRAG_MODEL_MAX_LEN = 200;
|
|
356
|
+
const UNSET_DEFRAG_MODEL = Object.freeze({ model: null, effort: null });
|
|
357
|
+
// The reader runs on every GET /api/settings, every memory report and every defragment run: a
|
|
358
|
+
// hand-edited bad value is one mistake — warn once per key + value, not once per request.
|
|
359
|
+
const warnedDefragModel = new Set();
|
|
360
|
+
function warnDefragModelOnce(key, value, tail) {
|
|
361
|
+
const id = `${key}:${JSON.stringify(value)}`;
|
|
362
|
+
if (warnedDefragModel.has(id)) return;
|
|
363
|
+
warnedDefragModel.add(id);
|
|
364
|
+
console.warn(`[worca] invalid memory.defrag.${key} ${JSON.stringify(value)} — ${tail}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The STORED pair: `{ model, effort }`, both null when unset (= the workflow default). Sync and
|
|
368
|
+
* never throws. An effort without a model means nothing and reads as unset; a bad value warns
|
|
369
|
+
* (once) and reads as unset. Under the node:test runner it reads unset unless the test sandboxes
|
|
370
|
+
* HOME and sets WORCA_TEST_ALLOW_HOME_FALLBACK (the listGlobalModels guard): settings.json lives
|
|
371
|
+
* under HOME, not WORCA_HOME, so a developer's own pick must never leak into a mock run. */
|
|
372
|
+
export function memoryDefragModel() {
|
|
373
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.WORCA_TEST_ALLOW_HOME_FALLBACK) return { ...UNSET_DEFRAG_MODEL };
|
|
374
|
+
const d = memoryBlock().defrag;
|
|
375
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return { ...UNSET_DEFRAG_MODEL }; // readDefragThreshold warns about the block
|
|
376
|
+
const m = d.model;
|
|
377
|
+
if (m === undefined) return { ...UNSET_DEFRAG_MODEL };
|
|
378
|
+
if (typeof m !== 'string' || !m.trim() || m.length > DEFRAG_MODEL_MAX_LEN) {
|
|
379
|
+
warnDefragModelOnce('model', m, 'defragment runs use the workflow default');
|
|
380
|
+
return { ...UNSET_DEFRAG_MODEL };
|
|
381
|
+
}
|
|
382
|
+
const e = d.effort;
|
|
383
|
+
const effort = typeof e === 'string' && EFFORTS.includes(e) ? e : null;
|
|
384
|
+
if (e !== undefined && effort === null) warnDefragModelOnce('effort', e, 'defragment runs use the model\'s default effort');
|
|
385
|
+
return { model: m.trim(), effort };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Validate a POST value: `{ model, effort }`, or null / '' to clear (a blank model clears the
|
|
390
|
+
* effort with it). With `models` (the effective catalog) the model must name an entry — it comes
|
|
391
|
+
* back in the catalog's casing — and the effort must be one that entry offers; without it the ids
|
|
392
|
+
* pass as given (tests, callers that validated already).
|
|
393
|
+
* @returns {{model:string, effort:(string|null)}|null} the pair to store, null to clear
|
|
394
|
+
* @throws {Error} on a malformed value, an effort without a model, an unknown model or an effort
|
|
395
|
+
* the model does not offer
|
|
396
|
+
*/
|
|
397
|
+
export function assertMemoryDefragModelInput(input, models = null) {
|
|
398
|
+
if (input === '' || input === null || input === undefined) return null;
|
|
399
|
+
if (typeof input !== 'object' || Array.isArray(input)) throw new Error('memoryDefrag must be { model, effort } or null');
|
|
400
|
+
const blank = (v) => v === null || v === undefined || (typeof v === 'string' && !v.trim());
|
|
401
|
+
if (blank(input.model)) {
|
|
402
|
+
if (!blank(input.effort)) throw new Error('memoryDefrag.effort needs a model — an effort without a model means nothing');
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (typeof input.model !== 'string' || !input.model.trim() || input.model.length > DEFRAG_MODEL_MAX_LEN) {
|
|
406
|
+
throw new Error('memoryDefrag.model must be a catalog model id');
|
|
407
|
+
}
|
|
408
|
+
const effortIn = typeof input.effort === 'string' ? input.effort.trim() : input.effort; // trimmed like setNodeModel / checkStartPair
|
|
409
|
+
if (!blank(effortIn) && !EFFORTS.includes(effortIn)) {
|
|
410
|
+
throw new Error(`memoryDefrag.effort must be one of ${EFFORTS.join(' | ')}`);
|
|
411
|
+
}
|
|
412
|
+
let model = input.model.trim();
|
|
413
|
+
const effort = blank(effortIn) ? null : effortIn;
|
|
414
|
+
if (Array.isArray(models)) {
|
|
415
|
+
const hit = models.find((m) => m && typeof m.id === 'string' && m.id.toLowerCase() === model.toLowerCase());
|
|
416
|
+
if (!hit) throw new Error(`unknown model "${model}" — add it to the catalog first`);
|
|
417
|
+
model = hit.id;
|
|
418
|
+
if (effort && !(Array.isArray(hit.efforts) && hit.efforts.includes(effort))) {
|
|
419
|
+
throw new Error(`${model} does not offer effort "${effort}"`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { model, effort };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Store (or, on null / a blank model, clear) the pair — read-modify-write of the `memory` block,
|
|
426
|
+
* so the health thresholds and every other memory key survive. A block left empty is removed. */
|
|
427
|
+
export async function setMemoryDefragModel(input, { models = null } = {}) {
|
|
428
|
+
const pair = assertMemoryDefragModelInput(input, models);
|
|
429
|
+
const settings = readSettings();
|
|
430
|
+
const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
431
|
+
const memory = isObj(settings.memory) ? { ...settings.memory } : {};
|
|
432
|
+
const defrag = isObj(memory.defrag) ? { ...memory.defrag } : {};
|
|
433
|
+
delete defrag.model;
|
|
434
|
+
delete defrag.effort;
|
|
435
|
+
if (pair) {
|
|
436
|
+
defrag.model = pair.model;
|
|
437
|
+
if (pair.effort) defrag.effort = pair.effort;
|
|
438
|
+
}
|
|
439
|
+
if (Object.keys(defrag).length) memory.defrag = defrag; else delete memory.defrag;
|
|
440
|
+
if (Object.keys(memory).length) settings.memory = memory; else delete settings.memory;
|
|
441
|
+
await persistSettings(settings);
|
|
442
|
+
return { memoryDefrag: memoryDefragModel() };
|
|
443
|
+
}
|
|
444
|
+
|
|
341
445
|
/** Skill delivery mechanism (§5.6): 'copy' (default, isolated) | 'symlink' (write-through). */
|
|
342
446
|
export function skillMount() {
|
|
343
447
|
const v = readSettings().skillMount;
|
|
@@ -407,6 +511,13 @@ export function pipelineCostLimitUsd() { return readUsdCap('pipelineCostLimitUsd
|
|
|
407
511
|
/** Windowed all-pipelines spend cap in USD, or null (no limit). */
|
|
408
512
|
export function totalCostLimitUsd() { return readUsdCap('totalCostLimitUsd'); }
|
|
409
513
|
|
|
514
|
+
/** Estimator constant overrides (money-saved design §4): `humanEstimate: { codeDiv: 40, … }`,
|
|
515
|
+
* or {}. Validation is the estimator's resolveConstants (unknown keys ignored). */
|
|
516
|
+
export function humanEstimateOverrides() {
|
|
517
|
+
const v = readSettings().humanEstimate;
|
|
518
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
519
|
+
}
|
|
520
|
+
|
|
410
521
|
/** Reset period for the total budget window: 'weekly' (Mon 00:00) | 'monthly' (1st 00:00). */
|
|
411
522
|
export function costLimitResetPeriod() {
|
|
412
523
|
const v = readSettings().costLimitResetPeriod;
|
|
@@ -535,15 +646,31 @@ export const setPipelineCostLimitUsd = (input) => setUsdCap('pipelineCostLimitUs
|
|
|
535
646
|
/** @throws {Error} unless `input` is a positive number (or empty, which clears). */
|
|
536
647
|
export const setTotalCostLimitUsd = (input) => setUsdCap('totalCostLimitUsd', input);
|
|
537
648
|
|
|
649
|
+
// ── Developer rate (money-saved design §8) ───────────────────────────────────
|
|
650
|
+
// Prices the estimated human hours in Statistics and Team metrics. Stored value or
|
|
651
|
+
// null; the EFFECTIVE rate (team policy default, then 35) is human-rate.mjs.
|
|
652
|
+
export const DEFAULT_HUMAN_RATE_USD = 35;
|
|
653
|
+
|
|
654
|
+
/** Stored developer rate in USD per hour, or null when unset (→ policy → 35). */
|
|
655
|
+
export function humanRateUsdPerHour() { return readUsdCap('humanRateUsdPerHour'); }
|
|
656
|
+
|
|
657
|
+
/** @throws {Error} unless a positive finite number, or '' / null / undefined (clear). */
|
|
658
|
+
export function assertHumanRateInput(input) { assertUsdCapInput('humanRateUsdPerHour', input); }
|
|
659
|
+
|
|
660
|
+
/** Write (or clear) the developer rate. */
|
|
661
|
+
export const setHumanRateUsdPerHour = (input) => setUsdCap('humanRateUsdPerHour', input);
|
|
662
|
+
|
|
538
663
|
// ── chat notification preferences (chat-connectivity-design.md §4.5) ─────────
|
|
539
664
|
|
|
540
665
|
const CHAT_NOTIFY_EVENTS = ['done', 'error', 'question', 'paused'];
|
|
541
666
|
|
|
542
667
|
/**
|
|
543
|
-
* Effective chat
|
|
668
|
+
* Effective chat preferences. Every notification event defaults ON; channels default
|
|
544
669
|
* enabled (an absent "<plugin>/<channelId>" key means enabled — presence with
|
|
545
|
-
* {enabled:false} is the opt-out record)
|
|
546
|
-
*
|
|
670
|
+
* {enabled:false} is the opt-out record); `scriptTools` (scripts-workbench W20) is the
|
|
671
|
+
* chat's "Create and run scripts" switch and defaults ON, so only a stored false ever
|
|
672
|
+
* takes save_script / test_script away.
|
|
673
|
+
* @returns {{notify: Record<string, boolean>, channels: Record<string, {enabled: boolean}>, scriptTools: boolean}}
|
|
547
674
|
*/
|
|
548
675
|
export function chatPrefs() {
|
|
549
676
|
const raw = readSettings().chat;
|
|
@@ -554,19 +681,22 @@ export function chatPrefs() {
|
|
|
554
681
|
for (const [key, v] of Object.entries(chat.channels && typeof chat.channels === 'object' ? chat.channels : {})) {
|
|
555
682
|
channels[key] = { enabled: v?.enabled !== false };
|
|
556
683
|
}
|
|
557
|
-
return { notify, channels };
|
|
684
|
+
return { notify, channels, scriptTools: chat.scriptTools !== false };
|
|
558
685
|
}
|
|
559
686
|
|
|
560
687
|
/**
|
|
561
688
|
* Merge-patch the chat prefs: {notify?: {done?, error?, question?, paused?},
|
|
562
|
-
* channels?: {"<plugin>/<id>"?: {enabled: boolean}}}. Unknown
|
|
563
|
-
* rejected (400 at the API layer);
|
|
689
|
+
* channels?: {"<plugin>/<id>"?: {enabled: boolean}}, scriptTools?: boolean}. Unknown
|
|
690
|
+
* notify keys and a non-boolean scriptTools are rejected (400 at the API layer);
|
|
691
|
+
* channels merge per key.
|
|
564
692
|
*/
|
|
565
693
|
export async function setChatPrefs(patch = {}) {
|
|
566
694
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('chat prefs must be an object');
|
|
567
695
|
for (const k of Object.keys(patch.notify || {})) {
|
|
568
696
|
if (!CHAT_NOTIFY_EVENTS.includes(k)) throw new Error(`unknown chat notify event "${k}"`);
|
|
569
697
|
}
|
|
698
|
+
const hasScriptTools = Object.prototype.hasOwnProperty.call(patch, 'scriptTools');
|
|
699
|
+
if (hasScriptTools && typeof patch.scriptTools !== 'boolean') throw new Error('chat scriptTools must be true or false');
|
|
570
700
|
const settings = readSettings();
|
|
571
701
|
const cur = settings.chat && typeof settings.chat === 'object' ? settings.chat : {};
|
|
572
702
|
settings.chat = {
|
|
@@ -578,6 +708,7 @@ export async function setChatPrefs(patch = {}) {
|
|
|
578
708
|
...Object.fromEntries(Object.entries(patch.channels).map(([k, v]) => [k, { enabled: v?.enabled !== false }])),
|
|
579
709
|
},
|
|
580
710
|
} : {}),
|
|
711
|
+
...(hasScriptTools ? { scriptTools: patch.scriptTools } : {}),
|
|
581
712
|
};
|
|
582
713
|
await persistSettings(settings);
|
|
583
714
|
return chatPrefs();
|
|
@@ -593,6 +724,42 @@ export async function setCostLimitResetPeriod(input) {
|
|
|
593
724
|
return { costLimitResetPeriod: costLimitResetPeriod() };
|
|
594
725
|
}
|
|
595
726
|
|
|
727
|
+
// ── Python interpreter (scripts-workbench spec §7) ───────────────────────────
|
|
728
|
+
// The second candidate the script-card probe tries, after the WORCA_PYTHON
|
|
729
|
+
// environment override and before the platform defaults. Settings-file-only, the
|
|
730
|
+
// company runRootMode / skillMount / the context caps keep: no /api/settings key
|
|
731
|
+
// and no Settings card in this version, so it is deliberately absent from
|
|
732
|
+
// SETTINGS_POST_KEYS below.
|
|
733
|
+
export const PYTHON_PATH_MAX_LEN = 500;
|
|
734
|
+
|
|
735
|
+
const isPythonPath = (v) => typeof v === 'string' && v.trim().length > 0 && v.length <= PYTHON_PATH_MAX_LEN;
|
|
736
|
+
|
|
737
|
+
/** The STORED interpreter path (trimmed), or null when unset/invalid (loudly). */
|
|
738
|
+
export function pythonPath() {
|
|
739
|
+
const v = readSettings().pythonPath;
|
|
740
|
+
if (v === undefined) return null;
|
|
741
|
+
if (isPythonPath(v)) return v.trim();
|
|
742
|
+
console.warn(`[worca] invalid pythonPath ${JSON.stringify(v)} — probing the platform defaults`);
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** @throws {Error} unless `input` is a non-empty path (or empty, which clears). */
|
|
747
|
+
export function assertPythonPathInput(input) {
|
|
748
|
+
if (isClearInput(input)) return;
|
|
749
|
+
if (!isPythonPath(input)) {
|
|
750
|
+
throw new Error(`pythonPath must be a path of at most ${PYTHON_PATH_MAX_LEN} characters, or empty to probe the platform defaults`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export async function setPythonPath(input) {
|
|
755
|
+
assertPythonPathInput(input);
|
|
756
|
+
const settings = readSettings();
|
|
757
|
+
if (isClearInput(input)) delete settings.pythonPath;
|
|
758
|
+
else settings.pythonPath = input.trim();
|
|
759
|
+
await persistSettings(settings);
|
|
760
|
+
return { pythonPath: pythonPath() };
|
|
761
|
+
}
|
|
762
|
+
|
|
596
763
|
// ── The keys POST /api/settings understands ──────────────────────────────────
|
|
597
764
|
// The route keeps a legacy contract: a body naming NONE of these clears root
|
|
598
765
|
// (test/settings-projects-root.test.mjs "a bodyless POST resets root"). Every
|
|
@@ -601,12 +768,15 @@ export async function setCostLimitResetPeriod(input) {
|
|
|
601
768
|
// its first save.
|
|
602
769
|
export const SETTINGS_POST_KEYS = Object.freeze([
|
|
603
770
|
'root', 'projectsRoot', 'chat',
|
|
604
|
-
'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod',
|
|
771
|
+
'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod', 'humanRateUsdPerHour',
|
|
605
772
|
'askMaxTurns', 'askMaxBudgetUsd',
|
|
606
773
|
'debugSpawnEnabled',
|
|
607
774
|
'titleModel', 'hideBuiltinModels',
|
|
608
775
|
'theme',
|
|
776
|
+
'uiLevel', // interface mode (docs/ui-levels.md)
|
|
609
777
|
'autoWorkflowModel', // auto-workflow spec D14
|
|
778
|
+
'memoryDefrag', // Settings › Memory: the defragment model + effort
|
|
779
|
+
'schedule', // scheduled-run defaults { graceMin, ifMissed, maxFailures }
|
|
610
780
|
]);
|
|
611
781
|
|
|
612
782
|
// ── Title-generation model + hidden built-ins (#422) ─────────────────────────
|
|
@@ -665,6 +835,9 @@ export async function setHideBuiltinModels(input) {
|
|
|
665
835
|
const settings = readSettings();
|
|
666
836
|
if (input === DEFAULT_HIDE_BUILTIN_MODELS) delete settings.hideBuiltinModels;
|
|
667
837
|
else settings.hideBuiltinModels = input;
|
|
838
|
+
// The developer has now made their own choice, even when it is the default: a team-policy
|
|
839
|
+
// `models.hideBuiltins` default no longer applies on this machine (team-policy design §6).
|
|
840
|
+
settings.hideBuiltinModelsChosen = true;
|
|
668
841
|
await persistSettings(settings);
|
|
669
842
|
return { hideBuiltinModels: hideBuiltinModels() };
|
|
670
843
|
}
|
|
@@ -832,15 +1005,38 @@ function sanitizeGlobalModel(raw) {
|
|
|
832
1005
|
env[k] = t;
|
|
833
1006
|
}
|
|
834
1007
|
const cost = sanitizeModelCost(raw.cost, id);
|
|
1008
|
+
const upstream = sanitizeModelUpstream(raw.upstream, id);
|
|
1009
|
+
if (upstream) {
|
|
1010
|
+
// The bridge owns the routing keys (model-env.mjs BRIDGE_ROUTING_KEYS); a
|
|
1011
|
+
// hand-edited file carrying both is degraded, not rejected: the bridge wins.
|
|
1012
|
+
const clash = upstreamEnvConflict(env);
|
|
1013
|
+
if (clash) {
|
|
1014
|
+
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping env key ${JSON.stringify(clash)} — the bridge sets it for an upstream entry`);
|
|
1015
|
+
for (const k of Object.keys(env)) if (upstreamEnvConflict({ [k]: env[k] })) delete env[k];
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
835
1018
|
return {
|
|
836
1019
|
id,
|
|
837
1020
|
label,
|
|
838
1021
|
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
839
1022
|
...(Object.keys(env).length ? { env } : {}),
|
|
840
1023
|
...(cost ? { cost } : {}),
|
|
1024
|
+
...(upstream ? { upstream } : {}),
|
|
841
1025
|
};
|
|
842
1026
|
}
|
|
843
1027
|
|
|
1028
|
+
/** Lenient read-side counterpart of assertModelUpstream: drops an invalid
|
|
1029
|
+
* `upstream` loudly instead of throwing. */
|
|
1030
|
+
function sanitizeModelUpstream(raw, id) {
|
|
1031
|
+
if (raw == null) return undefined;
|
|
1032
|
+
try {
|
|
1033
|
+
return assertModelUpstream(raw);
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping invalid upstream — ${e.message}`);
|
|
1036
|
+
return undefined;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
844
1040
|
/** Lenient read-side counterpart of assertModelCost (model-env.mjs): drops an
|
|
845
1041
|
* invalid `cost` loudly instead of throwing (a corrupt settings.json must never
|
|
846
1042
|
* break reads). */
|
|
@@ -934,16 +1130,26 @@ function assertTestSettingsAccess() {
|
|
|
934
1130
|
}
|
|
935
1131
|
|
|
936
1132
|
/** The MINIMAL stored shape for validated parts (see section comment). */
|
|
937
|
-
function storedModelShape(id, label, efforts, env, cost) {
|
|
1133
|
+
function storedModelShape(id, label, efforts, env, cost, upstream) {
|
|
938
1134
|
return {
|
|
939
1135
|
id,
|
|
940
1136
|
...(label && label !== id ? { label } : {}),
|
|
941
1137
|
...(efforts.length && efforts.length !== EFFORTS.length ? { efforts } : {}),
|
|
942
1138
|
...(Object.keys(env).length ? { env } : {}),
|
|
943
1139
|
...(cost ? { cost } : {}),
|
|
1140
|
+
...(upstream ? { upstream } : {}),
|
|
944
1141
|
};
|
|
945
1142
|
}
|
|
946
1143
|
|
|
1144
|
+
/** @throws {Error} when an `env` map carries a key the bridge owns for an upstream entry. */
|
|
1145
|
+
function assertUpstreamEnvCompatible(env, upstream) {
|
|
1146
|
+
if (!upstream) return;
|
|
1147
|
+
const clash = upstreamEnvConflict(env);
|
|
1148
|
+
if (clash) {
|
|
1149
|
+
throw new Error(`env key ${JSON.stringify(clash)} cannot be set on a model with an upstream — the bridge sets it (remove it or drop the upstream)`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
947
1153
|
/** Find the index of the raw `models` entry matching `id` (case-insensitive). */
|
|
948
1154
|
function findModelIndex(models, id) {
|
|
949
1155
|
const key = id.toLowerCase();
|
|
@@ -960,21 +1166,26 @@ function rawModels(settings) {
|
|
|
960
1166
|
* Add a global catalog entry. `label` defaults to the id; `efforts` must be a
|
|
961
1167
|
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved;
|
|
962
1168
|
* `cost` is an optional per-model override ({free} | {perMtok}, see assertModelCost).
|
|
1169
|
+
* `dryRun` validates exactly as a write would and returns the would-be entry
|
|
1170
|
+
* without persisting (Ask Worca's model card validates with it).
|
|
963
1171
|
* @returns {Promise<{id:string,label:string,efforts:string[],env?:object,cost?:object}>} the effective entry
|
|
964
1172
|
* @throws {Error} on invalid input or a case-insensitively duplicate id
|
|
965
1173
|
*/
|
|
966
|
-
export async function addGlobalModel({ id, label, efforts, env, cost } = {}) {
|
|
1174
|
+
export async function addGlobalModel({ id, label, efforts, env, cost, upstream } = {}, { dryRun = false } = {}) {
|
|
967
1175
|
assertTestSettingsAccess();
|
|
968
1176
|
const vid = assertModelId(id);
|
|
969
1177
|
if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
|
|
970
1178
|
const vefforts = assertEfforts(efforts);
|
|
971
1179
|
const venv = assertEnvPairs(env);
|
|
972
1180
|
const vcost = assertModelCost(cost);
|
|
1181
|
+
const vupstream = assertModelUpstream(upstream);
|
|
1182
|
+
assertUpstreamEnvCompatible(venv, vupstream);
|
|
973
1183
|
const settings = readSettings();
|
|
974
1184
|
const models = rawModels(settings);
|
|
975
1185
|
if (findModelIndex(models, vid) !== -1) throw new Error(`a model with id ${JSON.stringify(vid)} already exists`);
|
|
976
1186
|
const vlabel = (typeof label === 'string' && label.trim()) || vid;
|
|
977
|
-
|
|
1187
|
+
if (dryRun) return sanitizeGlobalModel(storedModelShape(vid, vlabel, vefforts, venv, vcost, vupstream));
|
|
1188
|
+
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv, vcost, vupstream)];
|
|
978
1189
|
await persistSettings(settings);
|
|
979
1190
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
980
1191
|
}
|
|
@@ -984,11 +1195,11 @@ export async function addGlobalModel({ id, label, efforts, env, cost } = {}) {
|
|
|
984
1195
|
* resets to the id. `efforts`: []/null resets to all. `env`: null clears the
|
|
985
1196
|
* whole map; an object merges per key, where a null value DELETES that key and
|
|
986
1197
|
* a string sets it (write-only PATCH semantics, design §4.10). `cost`: null/''
|
|
987
|
-
* removes the override; an object replaces it wholesale.
|
|
1198
|
+
* removes the override; an object replaces it wholesale. `dryRun` as addGlobalModel.
|
|
988
1199
|
* @returns {Promise<object>} the effective entry
|
|
989
1200
|
* @throws {Error} on an unknown id or invalid input
|
|
990
1201
|
*/
|
|
991
|
-
export async function updateGlobalModel(id, { label, efforts, env, cost } = {}) {
|
|
1202
|
+
export async function updateGlobalModel(id, { label, efforts, env, cost, upstream } = {}, { dryRun = false } = {}) {
|
|
992
1203
|
assertTestSettingsAccess();
|
|
993
1204
|
const vid = assertModelId(id);
|
|
994
1205
|
const settings = readSettings();
|
|
@@ -1021,8 +1232,15 @@ export async function updateGlobalModel(id, { label, efforts, env, cost } = {})
|
|
|
1021
1232
|
let nextCost = current.cost;
|
|
1022
1233
|
if (cost !== undefined) nextCost = isClearInput(cost) ? undefined : assertModelCost(cost);
|
|
1023
1234
|
|
|
1235
|
+
// upstream: omitted keeps the current block; null/'' removes it (the entry
|
|
1236
|
+
// reverts to plain env routing); an object replaces it wholesale.
|
|
1237
|
+
let nextUpstream = current.upstream;
|
|
1238
|
+
if (upstream !== undefined) nextUpstream = isClearInput(upstream) ? undefined : assertModelUpstream(upstream);
|
|
1239
|
+
assertUpstreamEnvCompatible(nextEnv, nextUpstream);
|
|
1240
|
+
|
|
1241
|
+
if (dryRun) return sanitizeGlobalModel(storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost, nextUpstream));
|
|
1024
1242
|
settings.models = models.slice();
|
|
1025
|
-
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost);
|
|
1243
|
+
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost, nextUpstream);
|
|
1026
1244
|
await persistSettings(settings);
|
|
1027
1245
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
1028
1246
|
}
|
|
@@ -1044,6 +1262,218 @@ export async function removeGlobalModel(id) {
|
|
|
1044
1262
|
await persistSettings(settings);
|
|
1045
1263
|
}
|
|
1046
1264
|
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
// Providers (model-bridge-design.md §6.2): account-level state the bridged
|
|
1267
|
+
// catalog entries share — one GitHub Copilot sign-in for every `copilot`
|
|
1268
|
+
// entry, one key/base URL for every `openai` entry. Stored under `providers`
|
|
1269
|
+
// in settings.json. Secrets are literal strings or whole-value ${VAR} refs
|
|
1270
|
+
// (resolved at use time from worca's own process.env), masked by the API.
|
|
1271
|
+
// The Copilot SHORT-LIVED token is never stored: providers/copilot.mjs keeps
|
|
1272
|
+
// it in memory. Readers are loud-and-lenient; setters throw.
|
|
1273
|
+
//
|
|
1274
|
+
// providers: {
|
|
1275
|
+
// copilot: { githubToken?, accountType?, acknowledgedTerms?, termsVersion?, maxConcurrent?, login? },
|
|
1276
|
+
// openai: { baseUrl?, apiKey?, maxConcurrent? },
|
|
1277
|
+
// anthropic: { baseUrl?, apiKey?, maxConcurrent? },
|
|
1278
|
+
// }
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
|
|
1281
|
+
const PROVIDER_SECRET_KEYS = Object.freeze({ copilot: ['githubToken'], openai: ['apiKey'], anthropic: ['apiKey'] });
|
|
1282
|
+
const PROVIDER_DEFAULT_BASE_URL = Object.freeze({ openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com' });
|
|
1283
|
+
|
|
1284
|
+
/** Sanitize one provider's stored block to its effective shape. Never throws. */
|
|
1285
|
+
function sanitizeProvider(name, raw) {
|
|
1286
|
+
const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
1287
|
+
const out = {};
|
|
1288
|
+
const warn = (k, why) => console.warn(`[worca] providers.${name}.${k}: ${why} — ignored`);
|
|
1289
|
+
for (const k of PROVIDER_SECRET_KEYS[name]) {
|
|
1290
|
+
if (r[k] === undefined) continue;
|
|
1291
|
+
if (typeof r[k] === 'string' && r[k].trim()) out[k] = r[k].trim();
|
|
1292
|
+
else warn(k, 'not a non-empty string');
|
|
1293
|
+
}
|
|
1294
|
+
if (name === 'copilot') {
|
|
1295
|
+
if (r.accountType !== undefined) {
|
|
1296
|
+
if (COPILOT_ACCOUNT_TYPES.includes(r.accountType)) out.accountType = r.accountType;
|
|
1297
|
+
else warn('accountType', `must be one of ${COPILOT_ACCOUNT_TYPES.join(' | ')}`);
|
|
1298
|
+
}
|
|
1299
|
+
if (r.acknowledgedTerms !== undefined) {
|
|
1300
|
+
if (typeof r.acknowledgedTerms === 'string' && !Number.isNaN(Date.parse(r.acknowledgedTerms))) out.acknowledgedTerms = r.acknowledgedTerms;
|
|
1301
|
+
else warn('acknowledgedTerms', 'not an ISO timestamp');
|
|
1302
|
+
}
|
|
1303
|
+
if (r.termsVersion !== undefined) {
|
|
1304
|
+
if (Number.isInteger(r.termsVersion) && r.termsVersion > 0) out.termsVersion = r.termsVersion;
|
|
1305
|
+
else warn('termsVersion', 'not a positive integer');
|
|
1306
|
+
}
|
|
1307
|
+
if (r.login !== undefined) {
|
|
1308
|
+
if (typeof r.login === 'string' && r.login.trim()) out.login = r.login.trim();
|
|
1309
|
+
else warn('login', 'not a non-empty string');
|
|
1310
|
+
}
|
|
1311
|
+
} else if (r.baseUrl !== undefined) {
|
|
1312
|
+
if (isUpstreamBaseUrl(r.baseUrl)) out.baseUrl = r.baseUrl.trim().replace(/\/+$/, '');
|
|
1313
|
+
else warn('baseUrl', 'not an http(s) URL');
|
|
1314
|
+
}
|
|
1315
|
+
if (r.maxConcurrent !== undefined) {
|
|
1316
|
+
const n = Number(r.maxConcurrent);
|
|
1317
|
+
if (Number.isInteger(n) && n >= 1 && n <= MAX_PROVIDER_CONCURRENCY) out.maxConcurrent = n;
|
|
1318
|
+
else warn('maxConcurrent', `must be an integer from 1 to ${MAX_PROVIDER_CONCURRENCY}`);
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* The EFFECTIVE provider config: stored values plus defaults (base URL,
|
|
1325
|
+
* concurrency, account type). Secrets are returned as stored (literal or
|
|
1326
|
+
* ${VAR}); use resolveProviderSecret for the live value. Never throws.
|
|
1327
|
+
* @param {string} name
|
|
1328
|
+
* @returns {object}
|
|
1329
|
+
*/
|
|
1330
|
+
export function providerConfig(name) {
|
|
1331
|
+
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
1332
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.WORCA_TEST_ALLOW_HOME_FALLBACK) return providerDefaults(name);
|
|
1333
|
+
const all = readSettings().providers;
|
|
1334
|
+
const raw = all && typeof all === 'object' && !Array.isArray(all) ? all[name] : undefined;
|
|
1335
|
+
return { ...providerDefaults(name), ...sanitizeProvider(name, raw) };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function providerDefaults(name) {
|
|
1339
|
+
const d = { maxConcurrent: DEFAULT_PROVIDER_CONCURRENCY[name] };
|
|
1340
|
+
if (name === 'copilot') d.accountType = 'individual';
|
|
1341
|
+
else d.baseUrl = PROVIDER_DEFAULT_BASE_URL[name];
|
|
1342
|
+
return d;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Every provider's effective config keyed by name. */
|
|
1346
|
+
export function allProviders() {
|
|
1347
|
+
return Object.fromEntries(UPSTREAM_PROVIDERS.map((n) => [n, providerConfig(n)]));
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** Whether a stored provider secret exists (as a literal or a ${VAR} ref). */
|
|
1351
|
+
export function providerSecretSet(name) {
|
|
1352
|
+
const cfg = providerConfig(name);
|
|
1353
|
+
return PROVIDER_SECRET_KEYS[name].some((k) => typeof cfg[k] === 'string' && cfg[k]);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* The live value of a stored secret: a `${VAR}` ref is read from `sourceEnv`
|
|
1358
|
+
* (unset → ''), a literal is returned as is.
|
|
1359
|
+
*/
|
|
1360
|
+
export function resolveProviderSecret(value, sourceEnv = process.env) {
|
|
1361
|
+
if (typeof value !== 'string' || !value) return '';
|
|
1362
|
+
const ref = modelEnvRef(value);
|
|
1363
|
+
if (ref === null) return value;
|
|
1364
|
+
const v = sourceEnv ? sourceEnv[ref] : undefined;
|
|
1365
|
+
return typeof v === 'string' ? v.trim() : '';
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Whether the Copilot terms notice has been acknowledged at the CURRENT wording version. */
|
|
1369
|
+
export function copilotTermsAcknowledged() {
|
|
1370
|
+
const c = providerConfig('copilot');
|
|
1371
|
+
return !!c.acknowledgedTerms && (c.termsVersion || 0) >= COPILOT_TERMS_VERSION;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Patch a provider's stored block. Omitted keys are kept; null/'' deletes a
|
|
1376
|
+
* key. Validates the whole patch before writing. `dryRun` returns the
|
|
1377
|
+
* would-be effective config without persisting.
|
|
1378
|
+
* @returns {Promise<object>} the effective config
|
|
1379
|
+
* @throws {Error}
|
|
1380
|
+
*/
|
|
1381
|
+
export async function updateProvider(name, patch = {}, { dryRun = false } = {}) {
|
|
1382
|
+
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
1383
|
+
assertTestSettingsAccess();
|
|
1384
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('provider patch must be an object');
|
|
1385
|
+
const allowed = new Set([...PROVIDER_SECRET_KEYS[name], 'maxConcurrent',
|
|
1386
|
+
...(name === 'copilot' ? ['accountType', 'acknowledgedTerms', 'termsVersion', 'login'] : ['baseUrl'])]);
|
|
1387
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1388
|
+
if (!allowed.has(k)) throw new Error(`unknown provider field ${JSON.stringify(k)} for ${name}`);
|
|
1389
|
+
if (isClearInput(v)) continue;
|
|
1390
|
+
if (PROVIDER_SECRET_KEYS[name].includes(k) || k === 'login') {
|
|
1391
|
+
if (typeof v !== 'string' || !v.trim()) throw new Error(`${k} must be a non-empty string`);
|
|
1392
|
+
} else if (k === 'accountType') {
|
|
1393
|
+
if (!COPILOT_ACCOUNT_TYPES.includes(v)) throw new Error(`accountType must be one of ${COPILOT_ACCOUNT_TYPES.join(' | ')}`);
|
|
1394
|
+
} else if (k === 'baseUrl') {
|
|
1395
|
+
if (!isUpstreamBaseUrl(v)) throw new Error('baseUrl must be an http(s) URL with no query or fragment');
|
|
1396
|
+
} else if (k === 'maxConcurrent') {
|
|
1397
|
+
const n = Number(v);
|
|
1398
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_PROVIDER_CONCURRENCY) throw new Error(`maxConcurrent must be an integer from 1 to ${MAX_PROVIDER_CONCURRENCY}`);
|
|
1399
|
+
} else if (k === 'acknowledgedTerms') {
|
|
1400
|
+
if (typeof v !== 'string' || Number.isNaN(Date.parse(v))) throw new Error('acknowledgedTerms must be an ISO timestamp');
|
|
1401
|
+
} else if (k === 'termsVersion') {
|
|
1402
|
+
if (!Number.isInteger(v) || v <= 0) throw new Error('termsVersion must be a positive integer');
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
const settings = readSettings();
|
|
1406
|
+
const all = settings.providers && typeof settings.providers === 'object' && !Array.isArray(settings.providers) ? settings.providers : {};
|
|
1407
|
+
const cur = all[name] && typeof all[name] === 'object' && !Array.isArray(all[name]) ? { ...all[name] } : {};
|
|
1408
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1409
|
+
if (isClearInput(v)) delete cur[k];
|
|
1410
|
+
else if (k === 'maxConcurrent') cur[k] = Number(v);
|
|
1411
|
+
else if (k === 'baseUrl') cur[k] = v.trim().replace(/\/+$/, '');
|
|
1412
|
+
else cur[k] = typeof v === 'string' ? v.trim() : v;
|
|
1413
|
+
}
|
|
1414
|
+
if (dryRun) return { ...providerDefaults(name), ...sanitizeProvider(name, cur) };
|
|
1415
|
+
if (Object.keys(cur).length) all[name] = cur; else delete all[name];
|
|
1416
|
+
if (Object.keys(all).length) settings.providers = all; else delete settings.providers;
|
|
1417
|
+
await persistSettings(settings);
|
|
1418
|
+
return providerConfig(name);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Record the Copilot terms acknowledgement at the current wording version. */
|
|
1422
|
+
export async function acknowledgeCopilotTerms(now = new Date()) {
|
|
1423
|
+
return updateProvider('copilot', { acknowledgedTerms: now.toISOString(), termsVersion: COPILOT_TERMS_VERSION });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Forget the Copilot sign-in (token + login); the acknowledgement stays. */
|
|
1427
|
+
export async function clearCopilotSignIn() {
|
|
1428
|
+
return updateProvider('copilot', { githubToken: null, login: null });
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ── Interface mode (docs/ui-levels.md) ───────────────────────────────────────
|
|
1432
|
+
// One machine-wide preference deciding how much of the web UI is on screen:
|
|
1433
|
+
// `simple` (the core loop), `advanced` (git, cost, workflows) or `expert`
|
|
1434
|
+
// (everything). A VIEW preference, never a permission. The server writes it into
|
|
1435
|
+
// the shell's <html data-level> at serve time, like the theme. The STORED value
|
|
1436
|
+
// may be absent: the default then depends on whether this is a fresh install
|
|
1437
|
+
// (defaultUiLevel), which only the server can tell, so `uiLevel()` answers null
|
|
1438
|
+
// for "never chosen" rather than guessing.
|
|
1439
|
+
export const UI_LEVELS = Object.freeze(['simple', 'advanced', 'expert']);
|
|
1440
|
+
const isUiLevel = (v) => UI_LEVELS.includes(v);
|
|
1441
|
+
|
|
1442
|
+
/** STORED interface mode, or null when never chosen. An invalid value is null (loudly). */
|
|
1443
|
+
export function uiLevel() {
|
|
1444
|
+
const v = readSettings().uiLevel;
|
|
1445
|
+
if (v === undefined) return null;
|
|
1446
|
+
if (isUiLevel(v)) return v;
|
|
1447
|
+
console.warn(`[worca] invalid uiLevel ${JSON.stringify(v)} — using the default`);
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* The mode for an install that never chose one. A fresh install starts simple;
|
|
1453
|
+
* an install that already has history starts expert, which is the UI it always
|
|
1454
|
+
* had, so an upgrade hides nothing.
|
|
1455
|
+
* @param {{fresh:boolean}} facts
|
|
1456
|
+
*/
|
|
1457
|
+
export function defaultUiLevel({ fresh } = {}) {
|
|
1458
|
+
return fresh ? 'simple' : 'expert';
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** @throws {Error} unless `input` is simple|advanced|expert, or empty/null (a clear). */
|
|
1462
|
+
export function assertUiLevelInput(input) {
|
|
1463
|
+
if (isClearInput(input)) return;
|
|
1464
|
+
if (!isUiLevel(input)) throw new Error('uiLevel must be simple, advanced or expert');
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Persist the mode. Every valid value is stored (there is no fixed default to elide); a clear deletes the key. */
|
|
1468
|
+
export async function setUiLevel(input) {
|
|
1469
|
+
assertUiLevelInput(input);
|
|
1470
|
+
const settings = readSettings();
|
|
1471
|
+
if (isClearInput(input)) delete settings.uiLevel;
|
|
1472
|
+
else settings.uiLevel = input;
|
|
1473
|
+
await persistSettings(settings);
|
|
1474
|
+
return { uiLevel: uiLevel() };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1047
1477
|
// ── Getting started (onboarding) ─────────────────────────────────────────────
|
|
1048
1478
|
// Two machine-wide flags, nothing more: which steps are DONE is derived from
|
|
1049
1479
|
// product state by src/core/onboarding.mjs and never stored. `hidden` is the
|
|
@@ -1074,3 +1504,48 @@ export async function setOnboardingPrefs(patch = {}) {
|
|
|
1074
1504
|
await persistSettings(settings);
|
|
1075
1505
|
return onboardingPrefs();
|
|
1076
1506
|
}
|
|
1507
|
+
|
|
1508
|
+
// ── Scheduled runs (schema v31) ──────────────────────────────────────────────
|
|
1509
|
+
// Defaults a new schedule inherits; each schedule stores its own copy, so a later
|
|
1510
|
+
// change here never rewrites an existing schedule. Read fresh, never throwing.
|
|
1511
|
+
// scheduleGraceMin — minutes a missed slot may still start late (default 360).
|
|
1512
|
+
// scheduleIfMissed — 'run' (start late inside the grace window) | 'skip'.
|
|
1513
|
+
// scheduleMaxFailures — consecutive failures before a recurring schedule pauses
|
|
1514
|
+
// itself; 0 = never (default 3).
|
|
1515
|
+
export const DEFAULT_SCHEDULE_GRACE_MIN = 360;
|
|
1516
|
+
export const DEFAULT_SCHEDULE_IF_MISSED = 'run';
|
|
1517
|
+
export const DEFAULT_SCHEDULE_MAX_FAILURES = 3;
|
|
1518
|
+
export const SCHEDULE_IF_MISSED = ['run', 'skip'];
|
|
1519
|
+
|
|
1520
|
+
const isGraceMin = (v) => Number.isSafeInteger(v) && v >= 0 && v <= 10080;
|
|
1521
|
+
const isMaxFailures = (v) => Number.isSafeInteger(v) && v >= 0 && v <= 100;
|
|
1522
|
+
|
|
1523
|
+
/** The schedule defaults: { graceMin, ifMissed, maxFailures }. */
|
|
1524
|
+
export function scheduleDefaults() {
|
|
1525
|
+
const s = readSettings();
|
|
1526
|
+
return {
|
|
1527
|
+
graceMin: isGraceMin(s.scheduleGraceMin) ? s.scheduleGraceMin : DEFAULT_SCHEDULE_GRACE_MIN,
|
|
1528
|
+
ifMissed: SCHEDULE_IF_MISSED.includes(s.scheduleIfMissed) ? s.scheduleIfMissed : DEFAULT_SCHEDULE_IF_MISSED,
|
|
1529
|
+
maxFailures: isMaxFailures(s.scheduleMaxFailures) ? s.scheduleMaxFailures : DEFAULT_SCHEDULE_MAX_FAILURES,
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Persist any subset of the schedule defaults; '' / null clears a key back to its
|
|
1535
|
+
* default. The whole patch is validated before anything is written.
|
|
1536
|
+
* @throws {Error} on the first invalid value
|
|
1537
|
+
*/
|
|
1538
|
+
export async function setScheduleDefaults(patch = {}) {
|
|
1539
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
|
|
1540
|
+
const clear = (v) => v === '' || v === null || v === undefined;
|
|
1541
|
+
if (has('graceMin') && !clear(patch.graceMin) && !isGraceMin(patch.graceMin)) throw new Error('scheduleGraceMin must be a whole number of minutes from 0 to 10080');
|
|
1542
|
+
if (has('ifMissed') && !clear(patch.ifMissed) && !SCHEDULE_IF_MISSED.includes(patch.ifMissed)) throw new Error(`scheduleIfMissed must be one of ${SCHEDULE_IF_MISSED.join(' | ')}`);
|
|
1543
|
+
if (has('maxFailures') && !clear(patch.maxFailures) && !isMaxFailures(patch.maxFailures)) throw new Error('scheduleMaxFailures must be a whole number from 0 to 100');
|
|
1544
|
+
const settings = readSettings();
|
|
1545
|
+
const put = (key, v) => { if (clear(v)) delete settings[key]; else settings[key] = v; };
|
|
1546
|
+
if (has('graceMin')) put('scheduleGraceMin', patch.graceMin);
|
|
1547
|
+
if (has('ifMissed')) put('scheduleIfMissed', patch.ifMissed);
|
|
1548
|
+
if (has('maxFailures')) put('scheduleMaxFailures', patch.maxFailures);
|
|
1549
|
+
await persistSettings(settings);
|
|
1550
|
+
return scheduleDefaults();
|
|
1551
|
+
}
|