@ran-sh/dsh-crew 1.3.2 → 1.5.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.
@@ -0,0 +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
+ }
package/src/policy.mjs CHANGED
@@ -9,23 +9,24 @@
9
9
  export * from './policy-legacy.mjs';
10
10
  import * as legacy from './policy-legacy.mjs';
11
11
  import { normalizeAdaptiveRouting } from './adaptive-routing.mjs';
12
+ import { normalizeModelSchedule } from './model-schedule.mjs';
12
13
 
13
- export { normalizeAdaptiveRouting };
14
- export const CONFIG_SCHEMA_VERSION = 4;
15
- const MIN_CANONICAL_SCHEMA_VERSION = 3;
16
-
17
- function validObject(value) {
18
- return !!value && typeof value === 'object' && !Array.isArray(value);
19
- }
20
-
21
- const EXECUTION_TRANSPORTS = new Set(['hub-3210', 'standalone-legacy']);
22
-
23
- function normalizeExecutionTransport(value) {
24
- return EXECUTION_TRANSPORTS.has(value) ? value : 'hub-3210';
25
- }
26
-
27
- function hasCanonicalAuthority(raw) {
28
- return Number(raw?.config_schema_version) >= MIN_CANONICAL_SCHEMA_VERSION
14
+ export { normalizeAdaptiveRouting };
15
+ export const CONFIG_SCHEMA_VERSION = 4;
16
+ const MIN_CANONICAL_SCHEMA_VERSION = 3;
17
+
18
+ function validObject(value) {
19
+ return !!value && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ const EXECUTION_TRANSPORTS = new Set(['hub-3210', 'standalone-legacy']);
23
+
24
+ function normalizeExecutionTransport(value) {
25
+ return EXECUTION_TRANSPORTS.has(value) ? value : 'hub-3210';
26
+ }
27
+
28
+ function hasCanonicalAuthority(raw) {
29
+ return Number(raw?.config_schema_version) >= MIN_CANONICAL_SCHEMA_VERSION
29
30
  && validObject(raw?.worker)
30
31
  && validObject(raw?.review)
31
32
  && validObject(raw?.execution);
@@ -71,44 +72,49 @@ function normalizeLegacySnapshot(raw, canonical) {
71
72
  };
72
73
  }
73
74
 
74
- function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
75
- const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive);
76
- const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
77
- ? rawPolicy.ordering
78
- : adaptive.enabled ? 'health-aware' : 'manual';
79
- return {
80
- ...basePolicy,
81
- adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
82
- ordering,
83
- health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
84
- };
85
- }
75
+ function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
76
+ const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive);
77
+ const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
78
+ ? rawPolicy.ordering
79
+ : adaptive.enabled ? 'health-aware' : 'manual';
80
+ return {
81
+ ...basePolicy,
82
+ adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
83
+ ordering,
84
+ health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
85
+ };
86
+ }
86
87
 
87
88
  function normalizeCanonical(raw) {
88
89
  const base = legacy.getCanonical(raw);
89
90
  const providerMode = legacy.normalizeWorkerProviderMode(base.worker?.provider_mode);
90
91
  const canonical = {
91
92
  ...base,
92
- execution: {
93
- ...base.execution,
93
+ execution: {
94
+ ...base.execution,
94
95
  // There is one global dispatch permission. `execution.enabled` is a
95
96
  // canonical mirror of the top-level switch, never a second authority.
96
- enabled: base.subagents_enabled,
97
- transport: normalizeExecutionTransport(raw?.execution?.transport ?? base.execution?.transport),
98
- },
97
+ enabled: base.subagents_enabled,
98
+ transport: normalizeExecutionTransport(raw?.execution?.transport ?? base.execution?.transport),
99
+ },
99
100
  worker: {
100
101
  ...base.worker,
101
102
  provider_mode: providerMode,
102
103
  model_policy: modelPolicyWithAdaptive(base.worker.model_policy, raw?.worker?.model_policy),
103
104
  },
104
- review: {
105
- ...base.review,
106
- gate: ['required', 'optional', 'off'].includes(raw?.review?.gate) ? raw.review.gate : 'required',
105
+ review: {
106
+ ...base.review,
107
+ gate: ['required', 'optional', 'off'].includes(raw?.review?.gate) ? raw.review.gate : 'required',
107
108
  // v0.3 still exposes one Worker Provider selector. Until per-role
108
109
  // provider modes become first-class, keep both roles aligned.
109
110
  provider_mode: providerMode,
110
111
  model_policy: modelPolicyWithAdaptive(base.review.model_policy, raw?.review?.model_policy),
111
112
  },
113
+ // One schedule regulates every restricted model; the per-model list decides
114
+ // who it applies to. Top-level rather than per-role because the same model
115
+ // can sit in either role's priority list, so the restriction is a property
116
+ // of the model, not of the role that happens to route to it.
117
+ model_schedule: normalizeModelSchedule(raw?.model_schedule),
112
118
  };
113
119
  canonical.legacy = normalizeLegacySnapshot(raw, canonical);
114
120
  return canonical;
@@ -170,6 +176,7 @@ export function normalizeGlobalConfig(raw = {}) {
170
176
  review_state: canonical.review.state,
171
177
  auto_review: canonical.review.auto_review === true,
172
178
  worker_provider_mode: canonical.worker.provider_mode,
179
+ model_schedule: canonical.model_schedule,
173
180
  flash_model_priority: [...canonical.worker.model_policy.priority],
174
181
  flash_model_priority_configured: canonical.worker.model_policy.priorityConfigured,
175
182
  flash_model_fallback: canonical.worker.model_policy.fallback,
@@ -191,32 +198,32 @@ export function normalizeGlobalConfig(raw = {}) {
191
198
  * adaptive sub-domain, so direct canonical-ish v0.2 callers retain the exact
192
199
  * priority/escalation semantics they had before schema-v3 existed.
193
200
  */
194
- export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
195
- const base = legacy.resolveModelPolicy(config, role, context);
196
- const rawPolicy = role === 'reviewer'
197
- ? config?.review?.model_policy
198
- : config?.worker?.model_policy;
199
- const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive);
200
- const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
201
- ? rawPolicy.ordering
202
- : adaptive.enabled ? 'health-aware' : 'manual';
203
- return {
204
- ...base,
205
- adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
206
- ordering,
207
- health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
208
- };
209
- }
210
-
211
- /** Automatic review respects the v4 reviewer gate; session opt-out remains authoritative. */
212
- export function shouldAutoReview(config = {}, session = {}) {
213
- const normalized = normalizeGlobalConfig(config);
214
- const gate = ['required', 'optional', 'off'].includes(config?.review?.gate)
215
- ? config.review.gate
216
- : normalized.review?.gate;
217
- if (gate === 'off') return false;
218
- return legacy.shouldAutoReview(normalized, session);
219
- }
201
+ export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
202
+ const base = legacy.resolveModelPolicy(config, role, context);
203
+ const rawPolicy = role === 'reviewer'
204
+ ? config?.review?.model_policy
205
+ : config?.worker?.model_policy;
206
+ const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive);
207
+ const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
208
+ ? rawPolicy.ordering
209
+ : adaptive.enabled ? 'health-aware' : 'manual';
210
+ return {
211
+ ...base,
212
+ adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
213
+ ordering,
214
+ health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
215
+ };
216
+ }
217
+
218
+ /** Automatic review respects the v4 reviewer gate; session opt-out remains authoritative. */
219
+ export function shouldAutoReview(config = {}, session = {}) {
220
+ const normalized = normalizeGlobalConfig(config);
221
+ const gate = ['required', 'optional', 'off'].includes(config?.review?.gate)
222
+ ? config.review.gate
223
+ : normalized.review?.gate;
224
+ if (gate === 'off') return false;
225
+ return legacy.shouldAutoReview(normalized, session);
226
+ }
220
227
 
221
228
  /** Explicit legacy-import normalizer for the persistence layer. */
222
229
  export function normalizeLegacyGlobalConfig(raw = {}) {
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.3.2';
39
+ export const RUNTIME_VERSION = '1.5.0';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
package/src/server.mjs CHANGED
@@ -421,7 +421,12 @@ async function buildConfigReport() {
421
421
  reason_code: workspaceReadiness.reason_code ?? workspaceReadiness.code ?? 'WORKSPACE_NOT_CHECKED',
422
422
  },
423
423
  };
424
- const modelCallability = reprojectRuntimeModelCallability(snapshot, { enabled_roles });
424
+ // `reprojectRuntimeModelCallability` takes `enabled_roles`; the local
425
+ // binding is camelCase. Naming the property matters: a bare
426
+ // `{ enabled_roles }` shorthand is a ReferenceError, and it fires on
427
+ // every report whenever the Hub returns a readiness snapshot, which is
428
+ // the normal case.
429
+ const modelCallability = reprojectRuntimeModelCallability(snapshot, { enabled_roles: enabledRoles });
425
430
  return modelCallability ? { ...snapshot, model_callability: modelCallability } : snapshot;
426
431
  })()
427
432
  : buildRuntimeReadinessSnapshot({
@@ -0,0 +1,60 @@
1
+ // Durable provenance for sessions the Crew hub created.
2
+ //
3
+ // Cleanup needs to tell a session Crew dispatched apart from one the operator
4
+ // opened in the same UI, because they are otherwise identical: both are ordinary
5
+ // Harness sessions in one shared store, with the same header shape (no field
6
+ // records who asked for them). Provenance has to be recorded at the moment of
7
+ // creation, which only the hub knows.
8
+ //
9
+ // This is deliberately an append-only JSONL file rather than a rewrite of the
10
+ // status shards: a shard removes itself on clean process exit, so it is a
11
+ // best-effort view of live writers, not a record of what was ever created.
12
+ //
13
+ // Absence is not proof of user authorship — a session predating this ledger, or
14
+ // one whose line was lost, simply stays unclaimed. Unclaimed sessions are treated
15
+ // as the operator's and are never removed by a Crew-scoped cleanup.
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ export function sessionOriginsFile({ home = homedir() } = {}) {
21
+ return join(home, '.config', 'dsh-crew', 'session-origins.jsonl');
22
+ }
23
+
24
+ /**
25
+ * Record that Crew created one session. Best effort: losing a line weakens a
26
+ * later cleanup's scope, so it must never fail a dispatch that already started.
27
+ */
28
+ export function appendSessionOrigin({ home = homedir(), sessionId, role = null, jobId = null, now = Date.now } = {}) {
29
+ if (typeof sessionId !== 'string' || !sessionId) return false;
30
+ try {
31
+ const file = sessionOriginsFile({ home });
32
+ mkdirSync(dirname(file), { recursive: true });
33
+ appendFileSync(file, `${JSON.stringify({ sessionId, role, jobId, createdAt: now() })}\n`);
34
+ return true;
35
+ } catch { return false; }
36
+ }
37
+
38
+ /**
39
+ * Session ids Crew is known to have created.
40
+ *
41
+ * A malformed or truncated line is skipped rather than thrown: the ledger is an
42
+ * optimization for scope, and a damaged tail must not make cleanup unusable. The
43
+ * failure direction is safe — an unread session is treated as the operator's.
44
+ */
45
+ export function readSessionOrigins({ home = homedir() } = {}) {
46
+ const file = sessionOriginsFile({ home });
47
+ if (!existsSync(file)) return new Set();
48
+ try {
49
+ const ids = new Set();
50
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
51
+ const trimmed = line.trim();
52
+ if (!trimmed) continue;
53
+ try {
54
+ const row = JSON.parse(trimmed);
55
+ if (typeof row?.sessionId === 'string' && row.sessionId) ids.add(row.sessionId);
56
+ } catch { /* skip a torn line */ }
57
+ }
58
+ return ids;
59
+ } catch { return new Set(); }
60
+ }