@ran-sh/dsh-crew 1.8.0 → 1.9.1

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.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +1 -1
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -332
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +455 -401
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -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
+ }