@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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/client.js +502 -12
- package/package.json +1 -1
- package/src/client/history-panel.tsx +4 -4
- package/src/client/index.tsx +159 -7
- package/src/history/cleanup-plan.mjs +30 -5
- package/src/history/runner-detach.mjs +34 -0
- package/src/history/runtime.mjs +9 -2
- package/src/history/service.mjs +13 -2
- package/src/hub/index.mjs +55 -0
- package/src/install/install-legacy.mjs +8 -0
- package/src/install/install.mjs +13 -5
- package/src/jobs.mjs +24 -2
- package/src/model-routing.mjs +250 -147
- package/src/model-schedule.mjs +207 -0
- package/src/policy.mjs +69 -62
- package/src/runtime-identity.mjs +1 -1
- package/src/server.mjs +6 -1
- package/src/session-origins.mjs +60 -0
|
@@ -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 = {}) {
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|