@ran-sh/dsh-crew 1.9.0 → 1.10.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/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- package/zcode/commands/dsh-status.md +5 -5
package/src/model-schedule.mjs
CHANGED
|
@@ -1,207 +1,207 @@
|
|
|
1
|
-
// Per-model peak/off-peak scheduling.
|
|
2
|
-
//
|
|
3
|
-
// Some providers price by time of day: DeepSeek charges double during its peak
|
|
4
|
-
// hours (UTC 01:00-04:00 and 06:00-10:00, Monday to Friday) and half that
|
|
5
|
-
// off-peak. An operator who wants to avoid that spend needs to say which models
|
|
6
|
-
// may run during peak, which must not, and which should merely be flagged.
|
|
7
|
-
//
|
|
8
|
-
// One schedule is shared; the per-model list decides who it applies to. A model
|
|
9
|
-
// absent from the list is unrestricted — the feature is opt-in per model, so
|
|
10
|
-
// turning it on never silently changes routing for everything at once.
|
|
11
|
-
//
|
|
12
|
-
// Wall-clock windows are stored in the configured offset, not UTC, so the value
|
|
13
|
-
// an operator types is the value they see. The defaults are DeepSeek's published
|
|
14
|
-
// peak hours (UTC 01:00-04:00 and 06:00-10:00, Mon-Fri) expressed in the default
|
|
15
|
-
// UTC+8: 09:00-12:00 and 14:00-18:00.
|
|
16
|
-
//
|
|
17
|
-
// This module is pure: no I/O, no ambient clock. Callers pass `at`.
|
|
18
|
-
|
|
19
|
-
/** Per-model restriction strength. Absent from the list means `off`. */
|
|
20
|
-
export const MODEL_SCHEDULE_MODES = Object.freeze(['off', 'warn', 'block']);
|
|
21
|
-
|
|
22
|
-
/** Peak means "expensive". Off-peak is every hour outside the windows. */
|
|
23
|
-
export const DEFAULT_PEAK_WINDOWS = Object.freeze([
|
|
24
|
-
Object.freeze({ start: '09:00', end: '12:00' }),
|
|
25
|
-
Object.freeze({ start: '14:00', end: '18:00' }),
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
export const DEFAULT_TIMEZONE_OFFSET_MINUTES = 8 * 60;
|
|
29
|
-
export const DEFAULT_PEAK_WEEKDAYS = Object.freeze([1, 2, 3, 4, 5]);
|
|
30
|
-
export const MINUTES_PER_DAY = 24 * 60;
|
|
31
|
-
// A fixed offset is bounded by the real-world range of UTC offsets; anything
|
|
32
|
-
// wider is a typo, not a timezone.
|
|
33
|
-
const MAX_OFFSET_MINUTES = 14 * 60;
|
|
34
|
-
|
|
35
|
-
/** "HH:MM" in 24-hour form to minutes past local midnight, or null. */
|
|
36
|
-
function clockMinutes(value) {
|
|
37
|
-
if (typeof value !== 'string') return null;
|
|
38
|
-
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
39
|
-
if (!match) return null;
|
|
40
|
-
const hours = Number(match[1]);
|
|
41
|
-
const minutes = Number(match[2]);
|
|
42
|
-
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
|
|
43
|
-
if (hours > 23 || minutes > 59) return null;
|
|
44
|
-
return hours * 60 + minutes;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function offsetMinutes(value, fallback) {
|
|
48
|
-
if (value === undefined || value === null) return fallback;
|
|
49
|
-
// Strict: a partially numeric string ("480garbage") or a float is a broken
|
|
50
|
-
// value, not a request for 480, and must not silently move the clock.
|
|
51
|
-
if (typeof value === 'number' ? !Number.isInteger(value) : !/^-?\d+$/.test(String(value).trim())) return null;
|
|
52
|
-
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value).trim(), 10);
|
|
53
|
-
if (Math.abs(parsed) > MAX_OFFSET_MINUTES) return null;
|
|
54
|
-
return parsed;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function weekdayList(value, fallback) {
|
|
58
|
-
if (value === undefined || value === null) return [...fallback];
|
|
59
|
-
// A present-but-unusable container is not a request for Mon-Fri: treating it
|
|
60
|
-
// as one could switch on restrictions the operator never specified.
|
|
61
|
-
if (!Array.isArray(value)) return null;
|
|
62
|
-
return [...new Set(value.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function peakWindows(value, fallback) {
|
|
66
|
-
if (value === undefined || value === null) return fallback.map((window) => ({ ...window }));
|
|
67
|
-
if (!Array.isArray(value)) return null;
|
|
68
|
-
const windows = [];
|
|
69
|
-
for (const entry of value) {
|
|
70
|
-
const start = clockMinutes(entry?.start);
|
|
71
|
-
const end = clockMinutes(entry?.end);
|
|
72
|
-
// A window that does not parse is dropped rather than widened to all day.
|
|
73
|
-
if (start === null || end === null || start === end) continue;
|
|
74
|
-
windows.push({ start: entry.start.trim(), end: entry.end.trim() });
|
|
75
|
-
}
|
|
76
|
-
return windows;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function modelModes(value) {
|
|
80
|
-
if (!Array.isArray(value)) return [];
|
|
81
|
-
const seen = new Set();
|
|
82
|
-
const modes = [];
|
|
83
|
-
for (const entry of value) {
|
|
84
|
-
const provider = typeof entry?.provider === 'string' ? entry.provider.trim() : '';
|
|
85
|
-
const model = typeof entry?.model === 'string' ? entry.model.trim() : '';
|
|
86
|
-
const mode = typeof entry?.mode === 'string' ? entry.mode.trim() : '';
|
|
87
|
-
if (!provider || !model || !MODEL_SCHEDULE_MODES.includes(mode)) continue;
|
|
88
|
-
// `off` is the absence of a rule, so it is not stored.
|
|
89
|
-
if (mode === 'off') continue;
|
|
90
|
-
const key = `${provider}\0${model}`;
|
|
91
|
-
if (seen.has(key)) continue;
|
|
92
|
-
seen.add(key);
|
|
93
|
-
modes.push({ provider, model, mode });
|
|
94
|
-
}
|
|
95
|
-
return modes;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Coerce a stored or user-supplied schedule into the canonical shape.
|
|
100
|
-
*
|
|
101
|
-
* Lenient toward *missing* fields, strict toward *broken* ones. A missing field
|
|
102
|
-
* gets its documented default; a field that is present but unusable (a string
|
|
103
|
-
* where a list belongs, a non-integer offset) makes the whole schedule
|
|
104
|
-
* non-restricting instead of substituting defaults. Substituting would let
|
|
105
|
-
* accidental corruption switch on restrictions the operator never asked for.
|
|
106
|
-
*/
|
|
107
|
-
export function normalizeModelSchedule(raw) {
|
|
108
|
-
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
109
|
-
// Keep the per-model rules even when the schedule turns inert: with no windows
|
|
110
|
-
// nothing is ever peak, so the rules cannot fire, and preserving them means a
|
|
111
|
-
// corrupted window does not also destroy the operator's model choices once the
|
|
112
|
-
// coerced config is written back.
|
|
113
|
-
const models = modelModes(source.models);
|
|
114
|
-
const offset = offsetMinutes(source.timezone_offset_minutes, DEFAULT_TIMEZONE_OFFSET_MINUTES);
|
|
115
|
-
const weekdays = weekdayList(source.weekdays, DEFAULT_PEAK_WEEKDAYS);
|
|
116
|
-
const windows = peakWindows(source.peak_windows, DEFAULT_PEAK_WINDOWS);
|
|
117
|
-
if (offset === null || weekdays === null || windows === null) {
|
|
118
|
-
// Fail open by emptying the axis that activates restrictions — weekdays
|
|
119
|
-
// schedule the windows — while keeping every field that still parses. A
|
|
120
|
-
// whole-schedule reset would discard a valid custom offset and windows, and
|
|
121
|
-
// the panel writes the schedule as a whole, so that loss could stick.
|
|
122
|
-
return {
|
|
123
|
-
timezone_offset_minutes: offset === null ? DEFAULT_TIMEZONE_OFFSET_MINUTES : offset,
|
|
124
|
-
weekdays: [],
|
|
125
|
-
peak_windows: windows === null ? [] : windows,
|
|
126
|
-
models,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
timezone_offset_minutes: offset,
|
|
131
|
-
weekdays,
|
|
132
|
-
peak_windows: windows,
|
|
133
|
-
models,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function defaultModelSchedule() {
|
|
138
|
-
return normalizeModelSchedule({
|
|
139
|
-
timezone_offset_minutes: DEFAULT_TIMEZONE_OFFSET_MINUTES,
|
|
140
|
-
weekdays: [...DEFAULT_PEAK_WEEKDAYS],
|
|
141
|
-
peak_windows: DEFAULT_PEAK_WINDOWS.map((window) => ({ ...window })),
|
|
142
|
-
models: [],
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Local wall clock for an instant under a fixed offset. */
|
|
147
|
-
function localParts(at, offset) {
|
|
148
|
-
const shifted = new Date(at.getTime() + offset * 60_000);
|
|
149
|
-
return {
|
|
150
|
-
weekday: shifted.getUTCDay(),
|
|
151
|
-
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function inWindow(minutes, start, end) {
|
|
156
|
-
return start < end
|
|
157
|
-
? minutes >= start && minutes < end
|
|
158
|
-
// A window crossing midnight runs from `start` on one day to `end` the next.
|
|
159
|
-
: minutes >= start || minutes < end;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Whether one instant falls in a peak window.
|
|
164
|
-
*
|
|
165
|
-
* A window crossing midnight also belongs to the weekday it starts on, so the
|
|
166
|
-
* early-morning half is checked against the previous day.
|
|
167
|
-
*/
|
|
168
|
-
export function isPeakAt(schedule, at = new Date()) {
|
|
169
|
-
const normalized = normalizeModelSchedule(schedule);
|
|
170
|
-
if (normalized.weekdays.length === 0) return false;
|
|
171
|
-
const { weekday, minutes } = localParts(at, normalized.timezone_offset_minutes);
|
|
172
|
-
for (const window of normalized.peak_windows) {
|
|
173
|
-
const start = clockMinutes(window.start);
|
|
174
|
-
const end = clockMinutes(window.end);
|
|
175
|
-
if (start === null || end === null) continue;
|
|
176
|
-
if (!inWindow(minutes, start, end)) continue;
|
|
177
|
-
const owner = start < end || minutes >= start ? weekday : (weekday + 6) % 7;
|
|
178
|
-
if (normalized.weekdays.includes(owner)) return true;
|
|
179
|
-
}
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** The configured mode for one model ref; an unlisted model is unrestricted. */
|
|
184
|
-
export function modelScheduleMode(schedule, ref) {
|
|
185
|
-
const provider = typeof ref?.provider === 'string' ? ref.provider.trim() : '';
|
|
186
|
-
const model = typeof ref?.model === 'string' ? ref.model.trim() : '';
|
|
187
|
-
if (!provider || !model) return 'off';
|
|
188
|
-
const entry = normalizeModelSchedule(schedule).models.find(
|
|
189
|
-
(candidate) => candidate.provider === provider && candidate.model === model,
|
|
190
|
-
);
|
|
191
|
-
return entry?.mode ?? 'off';
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Decide whether one model ref is currently peak-restricted.
|
|
196
|
-
*
|
|
197
|
-
* Returns `null` when the model is unrestricted, off-peak, or the schedule is
|
|
198
|
-
* empty; otherwise `{ mode, peak: true }` where `mode` is `warn` or `block`.
|
|
199
|
-
* The caller decides: `block` skips the candidate, `warn` selects it and records
|
|
200
|
-
* the advisory.
|
|
201
|
-
*/
|
|
202
|
-
export function scheduleAdmission(schedule, ref, at = new Date()) {
|
|
203
|
-
const mode = modelScheduleMode(schedule, ref);
|
|
204
|
-
if (mode === 'off') return null;
|
|
205
|
-
if (!isPeakAt(schedule, at)) return null;
|
|
206
|
-
return { mode, peak: true };
|
|
207
|
-
}
|
|
1
|
+
// Per-model peak/off-peak scheduling.
|
|
2
|
+
//
|
|
3
|
+
// Some providers price by time of day: DeepSeek charges double during its peak
|
|
4
|
+
// hours (UTC 01:00-04:00 and 06:00-10:00, Monday to Friday) and half that
|
|
5
|
+
// off-peak. An operator who wants to avoid that spend needs to say which models
|
|
6
|
+
// may run during peak, which must not, and which should merely be flagged.
|
|
7
|
+
//
|
|
8
|
+
// One schedule is shared; the per-model list decides who it applies to. A model
|
|
9
|
+
// absent from the list is unrestricted — the feature is opt-in per model, so
|
|
10
|
+
// turning it on never silently changes routing for everything at once.
|
|
11
|
+
//
|
|
12
|
+
// Wall-clock windows are stored in the configured offset, not UTC, so the value
|
|
13
|
+
// an operator types is the value they see. The defaults are DeepSeek's published
|
|
14
|
+
// peak hours (UTC 01:00-04:00 and 06:00-10:00, Mon-Fri) expressed in the default
|
|
15
|
+
// UTC+8: 09:00-12:00 and 14:00-18:00.
|
|
16
|
+
//
|
|
17
|
+
// This module is pure: no I/O, no ambient clock. Callers pass `at`.
|
|
18
|
+
|
|
19
|
+
/** Per-model restriction strength. Absent from the list means `off`. */
|
|
20
|
+
export const MODEL_SCHEDULE_MODES = Object.freeze(['off', 'warn', 'block']);
|
|
21
|
+
|
|
22
|
+
/** Peak means "expensive". Off-peak is every hour outside the windows. */
|
|
23
|
+
export const DEFAULT_PEAK_WINDOWS = Object.freeze([
|
|
24
|
+
Object.freeze({ start: '09:00', end: '12:00' }),
|
|
25
|
+
Object.freeze({ start: '14:00', end: '18:00' }),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_TIMEZONE_OFFSET_MINUTES = 8 * 60;
|
|
29
|
+
export const DEFAULT_PEAK_WEEKDAYS = Object.freeze([1, 2, 3, 4, 5]);
|
|
30
|
+
export const MINUTES_PER_DAY = 24 * 60;
|
|
31
|
+
// A fixed offset is bounded by the real-world range of UTC offsets; anything
|
|
32
|
+
// wider is a typo, not a timezone.
|
|
33
|
+
const MAX_OFFSET_MINUTES = 14 * 60;
|
|
34
|
+
|
|
35
|
+
/** "HH:MM" in 24-hour form to minutes past local midnight, or null. */
|
|
36
|
+
function clockMinutes(value) {
|
|
37
|
+
if (typeof value !== 'string') return null;
|
|
38
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const hours = Number(match[1]);
|
|
41
|
+
const minutes = Number(match[2]);
|
|
42
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
|
|
43
|
+
if (hours > 23 || minutes > 59) return null;
|
|
44
|
+
return hours * 60 + minutes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function offsetMinutes(value, fallback) {
|
|
48
|
+
if (value === undefined || value === null) return fallback;
|
|
49
|
+
// Strict: a partially numeric string ("480garbage") or a float is a broken
|
|
50
|
+
// value, not a request for 480, and must not silently move the clock.
|
|
51
|
+
if (typeof value === 'number' ? !Number.isInteger(value) : !/^-?\d+$/.test(String(value).trim())) return null;
|
|
52
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value).trim(), 10);
|
|
53
|
+
if (Math.abs(parsed) > MAX_OFFSET_MINUTES) return null;
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function weekdayList(value, fallback) {
|
|
58
|
+
if (value === undefined || value === null) return [...fallback];
|
|
59
|
+
// A present-but-unusable container is not a request for Mon-Fri: treating it
|
|
60
|
+
// as one could switch on restrictions the operator never specified.
|
|
61
|
+
if (!Array.isArray(value)) return null;
|
|
62
|
+
return [...new Set(value.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function peakWindows(value, fallback) {
|
|
66
|
+
if (value === undefined || value === null) return fallback.map((window) => ({ ...window }));
|
|
67
|
+
if (!Array.isArray(value)) return null;
|
|
68
|
+
const windows = [];
|
|
69
|
+
for (const entry of value) {
|
|
70
|
+
const start = clockMinutes(entry?.start);
|
|
71
|
+
const end = clockMinutes(entry?.end);
|
|
72
|
+
// A window that does not parse is dropped rather than widened to all day.
|
|
73
|
+
if (start === null || end === null || start === end) continue;
|
|
74
|
+
windows.push({ start: entry.start.trim(), end: entry.end.trim() });
|
|
75
|
+
}
|
|
76
|
+
return windows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function modelModes(value) {
|
|
80
|
+
if (!Array.isArray(value)) return [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const modes = [];
|
|
83
|
+
for (const entry of value) {
|
|
84
|
+
const provider = typeof entry?.provider === 'string' ? entry.provider.trim() : '';
|
|
85
|
+
const model = typeof entry?.model === 'string' ? entry.model.trim() : '';
|
|
86
|
+
const mode = typeof entry?.mode === 'string' ? entry.mode.trim() : '';
|
|
87
|
+
if (!provider || !model || !MODEL_SCHEDULE_MODES.includes(mode)) continue;
|
|
88
|
+
// `off` is the absence of a rule, so it is not stored.
|
|
89
|
+
if (mode === 'off') continue;
|
|
90
|
+
const key = `${provider}\0${model}`;
|
|
91
|
+
if (seen.has(key)) continue;
|
|
92
|
+
seen.add(key);
|
|
93
|
+
modes.push({ provider, model, mode });
|
|
94
|
+
}
|
|
95
|
+
return modes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Coerce a stored or user-supplied schedule into the canonical shape.
|
|
100
|
+
*
|
|
101
|
+
* Lenient toward *missing* fields, strict toward *broken* ones. A missing field
|
|
102
|
+
* gets its documented default; a field that is present but unusable (a string
|
|
103
|
+
* where a list belongs, a non-integer offset) makes the whole schedule
|
|
104
|
+
* non-restricting instead of substituting defaults. Substituting would let
|
|
105
|
+
* accidental corruption switch on restrictions the operator never asked for.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeModelSchedule(raw) {
|
|
108
|
+
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
109
|
+
// Keep the per-model rules even when the schedule turns inert: with no windows
|
|
110
|
+
// nothing is ever peak, so the rules cannot fire, and preserving them means a
|
|
111
|
+
// corrupted window does not also destroy the operator's model choices once the
|
|
112
|
+
// coerced config is written back.
|
|
113
|
+
const models = modelModes(source.models);
|
|
114
|
+
const offset = offsetMinutes(source.timezone_offset_minutes, DEFAULT_TIMEZONE_OFFSET_MINUTES);
|
|
115
|
+
const weekdays = weekdayList(source.weekdays, DEFAULT_PEAK_WEEKDAYS);
|
|
116
|
+
const windows = peakWindows(source.peak_windows, DEFAULT_PEAK_WINDOWS);
|
|
117
|
+
if (offset === null || weekdays === null || windows === null) {
|
|
118
|
+
// Fail open by emptying the axis that activates restrictions — weekdays
|
|
119
|
+
// schedule the windows — while keeping every field that still parses. A
|
|
120
|
+
// whole-schedule reset would discard a valid custom offset and windows, and
|
|
121
|
+
// the panel writes the schedule as a whole, so that loss could stick.
|
|
122
|
+
return {
|
|
123
|
+
timezone_offset_minutes: offset === null ? DEFAULT_TIMEZONE_OFFSET_MINUTES : offset,
|
|
124
|
+
weekdays: [],
|
|
125
|
+
peak_windows: windows === null ? [] : windows,
|
|
126
|
+
models,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
timezone_offset_minutes: offset,
|
|
131
|
+
weekdays,
|
|
132
|
+
peak_windows: windows,
|
|
133
|
+
models,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function defaultModelSchedule() {
|
|
138
|
+
return normalizeModelSchedule({
|
|
139
|
+
timezone_offset_minutes: DEFAULT_TIMEZONE_OFFSET_MINUTES,
|
|
140
|
+
weekdays: [...DEFAULT_PEAK_WEEKDAYS],
|
|
141
|
+
peak_windows: DEFAULT_PEAK_WINDOWS.map((window) => ({ ...window })),
|
|
142
|
+
models: [],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Local wall clock for an instant under a fixed offset. */
|
|
147
|
+
function localParts(at, offset) {
|
|
148
|
+
const shifted = new Date(at.getTime() + offset * 60_000);
|
|
149
|
+
return {
|
|
150
|
+
weekday: shifted.getUTCDay(),
|
|
151
|
+
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function inWindow(minutes, start, end) {
|
|
156
|
+
return start < end
|
|
157
|
+
? minutes >= start && minutes < end
|
|
158
|
+
// A window crossing midnight runs from `start` on one day to `end` the next.
|
|
159
|
+
: minutes >= start || minutes < end;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether one instant falls in a peak window.
|
|
164
|
+
*
|
|
165
|
+
* A window crossing midnight also belongs to the weekday it starts on, so the
|
|
166
|
+
* early-morning half is checked against the previous day.
|
|
167
|
+
*/
|
|
168
|
+
export function isPeakAt(schedule, at = new Date()) {
|
|
169
|
+
const normalized = normalizeModelSchedule(schedule);
|
|
170
|
+
if (normalized.weekdays.length === 0) return false;
|
|
171
|
+
const { weekday, minutes } = localParts(at, normalized.timezone_offset_minutes);
|
|
172
|
+
for (const window of normalized.peak_windows) {
|
|
173
|
+
const start = clockMinutes(window.start);
|
|
174
|
+
const end = clockMinutes(window.end);
|
|
175
|
+
if (start === null || end === null) continue;
|
|
176
|
+
if (!inWindow(minutes, start, end)) continue;
|
|
177
|
+
const owner = start < end || minutes >= start ? weekday : (weekday + 6) % 7;
|
|
178
|
+
if (normalized.weekdays.includes(owner)) return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The configured mode for one model ref; an unlisted model is unrestricted. */
|
|
184
|
+
export function modelScheduleMode(schedule, ref) {
|
|
185
|
+
const provider = typeof ref?.provider === 'string' ? ref.provider.trim() : '';
|
|
186
|
+
const model = typeof ref?.model === 'string' ? ref.model.trim() : '';
|
|
187
|
+
if (!provider || !model) return 'off';
|
|
188
|
+
const entry = normalizeModelSchedule(schedule).models.find(
|
|
189
|
+
(candidate) => candidate.provider === provider && candidate.model === model,
|
|
190
|
+
);
|
|
191
|
+
return entry?.mode ?? 'off';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decide whether one model ref is currently peak-restricted.
|
|
196
|
+
*
|
|
197
|
+
* Returns `null` when the model is unrestricted, off-peak, or the schedule is
|
|
198
|
+
* empty; otherwise `{ mode, peak: true }` where `mode` is `warn` or `block`.
|
|
199
|
+
* The caller decides: `block` skips the candidate, `warn` selects it and records
|
|
200
|
+
* the advisory.
|
|
201
|
+
*/
|
|
202
|
+
export function scheduleAdmission(schedule, ref, at = new Date()) {
|
|
203
|
+
const mode = modelScheduleMode(schedule, ref);
|
|
204
|
+
if (mode === 'off') return null;
|
|
205
|
+
if (!isPeakAt(schedule, at)) return null;
|
|
206
|
+
return { mode, peak: true };
|
|
207
|
+
}
|