@ran-sh/dsh-crew 0.5.6 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/README.zh.md +12 -7
- package/docs/installation.md +1 -1
- package/docs/ui-surfaces.md +65 -46
- package/lib/client.js +345 -22
- package/official-web-bridge/lib/client.js +345 -22
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +3 -2
- package/src/client/index.tsx +227 -51
- package/src/config-readiness.mjs +56 -5
- package/src/extension-contract.mjs +9 -5
- package/src/hub/index.mjs +854 -445
- package/src/hub-client.mjs +24 -4
- package/src/hub-compatibility.mjs +23 -7
- package/src/install/install.mjs +4 -4
- package/src/install/npx-lifecycle.mjs +263 -118
- package/src/job-contracts.mjs +19 -3
- package/src/jobs.mjs +2 -4
- package/src/local-request-guard.mjs +60 -0
- package/src/mcp-runtime.mjs +7 -5
- package/src/model-routing.mjs +141 -49
- package/src/multimodal.mjs +0 -0
- package/src/official-web-bridge.mjs +242 -84
- package/src/policy.mjs +62 -31
- package/src/provider-config-scrub.mjs +71 -0
- package/src/provider-delete-adapters.mjs +424 -0
- package/src/provider-health.mjs +124 -0
- package/src/provider-inventory.mjs +132 -0
- package/src/provider-lifecycle-state.mjs +81 -0
- package/src/provider-lifecycle.mjs +214 -0
- package/src/provider-profile-store.mjs +171 -0
- package/src/readiness-matrix.mjs +11 -5
- package/src/removable-waiter.mjs +29 -0
- package/src/runtime-identity.mjs +43 -13
- package/src/server.mjs +34 -16
- package/src/status-shard.mjs +13 -2
- package/src/workflow-runtime.mjs +44 -10
- package/windows/start-dsh-crew.cmd +3 -3
- package/windows/start-dsh-crew.ps1 +49 -5
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Filesystem-backed adapters for the provider deletion transaction.
|
|
2
|
+
//
|
|
3
|
+
// The transaction state machine remains side-effect free. This module owns
|
|
4
|
+
// only the three Crew-managed files that can be changed by a provider delete:
|
|
5
|
+
// the Harness provider patch, the canonical Crew config, and the lifecycle
|
|
6
|
+
// tombstone file. Credentials are deliberately never read or copied.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { basename, dirname, join } from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
inspectProviderProfile,
|
|
22
|
+
readProviderDeclarations,
|
|
23
|
+
removeProviderDeclarations,
|
|
24
|
+
} from './provider-profile-store.mjs';
|
|
25
|
+
import { scrubProviderReferences } from './provider-config-scrub.mjs';
|
|
26
|
+
import {
|
|
27
|
+
markProviderTombstone,
|
|
28
|
+
normalizeProviderLifecycleState,
|
|
29
|
+
recordProviderTransaction,
|
|
30
|
+
} from './provider-lifecycle-state.mjs';
|
|
31
|
+
|
|
32
|
+
const FILE_KEYS = Object.freeze(['profile', 'config', 'lifecycle']);
|
|
33
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
34
|
+
|
|
35
|
+
function validProviderId(value) {
|
|
36
|
+
return typeof value === 'string' && ID_PATTERN.test(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sha256(value) {
|
|
40
|
+
return createHash('sha256').update(String(value), 'utf8').digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safePlanId(value) {
|
|
44
|
+
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value) ? value : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function atomicWrite(file, content) {
|
|
48
|
+
const dir = dirname(file);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const temp = join(dir, `.${basename(file)}.${process.pid}.${Date.now()}.dsh-crew.tmp`);
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(temp, content);
|
|
53
|
+
renameSync(temp, file);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
try { rmSync(temp, { force: true }); } catch {}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson(file, fallback) {
|
|
61
|
+
if (!existsSync(file)) return fallback;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
throw Object.assign(new Error('managed provider file is invalid'), { code: 'PROVIDER_DELETE_FILE_INVALID' });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultReadConfig(configFile) {
|
|
70
|
+
assertManagedPath(configFile);
|
|
71
|
+
return readJson(configFile, {});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultWriteConfig(configFile, config) {
|
|
75
|
+
atomicWrite(configFile, JSON.stringify(config, null, 2) + '\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function profileContainsProvider(source, providerId) {
|
|
79
|
+
const escaped = providerId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
+
return new RegExp(`^\\s+${escaped}:\\s*$`, 'm').test(source);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fileMap({ profileFile, configFile, lifecycleFile }) {
|
|
84
|
+
return { profile: profileFile, config: configFile, lifecycle: lifecycleFile };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasOwn(value, key) {
|
|
88
|
+
return !!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeModelRef(value) {
|
|
92
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value ?? null;
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const key of ['provider', 'model', 'reasoningEffort']) {
|
|
95
|
+
if (typeof value[key] === 'string' && value[key].trim()) result[key] = value[key].trim();
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function safeModelRefList(value) {
|
|
101
|
+
return Array.isArray(value) ? value.map((entry) => safeModelRef(entry)) : value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function configProjection(config) {
|
|
105
|
+
const projection = { schema_version: 1, fields: {} };
|
|
106
|
+
for (const key of ['flash_model_priority', 'pro_model_priority', 'harness_default', 'agent_default_model', 'agentDefaultModel']) {
|
|
107
|
+
if (hasOwn(config, key)) projection.fields[key] = key.endsWith('_priority') ? safeModelRefList(config[key]) : safeModelRef(config[key]);
|
|
108
|
+
}
|
|
109
|
+
for (const [scope, value] of [['worker', config?.worker?.model_policy], ['review', config?.review?.model_policy]]) {
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
111
|
+
const fields = {};
|
|
112
|
+
for (const key of ['priority', 'escalation_priority']) if (hasOwn(value, key)) fields[key] = safeModelRefList(value[key]);
|
|
113
|
+
if (Object.keys(fields).length > 0) projection.fields[scope] = { model_policy: fields };
|
|
114
|
+
}
|
|
115
|
+
return projection;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function restoreConfigProjection(config, projection) {
|
|
119
|
+
const next = typeof structuredClone === 'function' ? structuredClone(config) : JSON.parse(JSON.stringify(config));
|
|
120
|
+
for (const key of ['flash_model_priority', 'pro_model_priority', 'harness_default', 'agent_default_model', 'agentDefaultModel']) {
|
|
121
|
+
if (hasOwn(projection?.fields, key)) next[key] = projection.fields[key];
|
|
122
|
+
}
|
|
123
|
+
for (const scope of ['worker', 'review']) {
|
|
124
|
+
const saved = projection?.fields?.[scope]?.model_policy;
|
|
125
|
+
if (!saved) continue;
|
|
126
|
+
next[scope] = { ...(next[scope] ?? {}), model_policy: { ...(next[scope]?.model_policy ?? {}) } };
|
|
127
|
+
for (const key of ['priority', 'escalation_priority']) if (hasOwn(saved, key)) next[scope].model_policy[key] = saved[key];
|
|
128
|
+
}
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function safeRestoreFile(source, target) {
|
|
133
|
+
if (lstatSync(source).isSymbolicLink()) throw Object.assign(new Error('backup file is a symlink'), { code: 'PROVIDER_DELETE_ROLLBACK_UNSAFE_PATH' });
|
|
134
|
+
try {
|
|
135
|
+
if (lstatSync(target).isSymbolicLink()) rmSync(target, { force: true });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
138
|
+
}
|
|
139
|
+
atomicWrite(target, readFileSync(source));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertManagedPath(file) {
|
|
143
|
+
try {
|
|
144
|
+
if (lstatSync(file).isSymbolicLink()) {
|
|
145
|
+
throw Object.assign(new Error('managed provider path is a symlink'), { code: 'PROVIDER_DELETE_UNSAFE_PATH' });
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code === 'ENOENT') return;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validatePaths(paths) {
|
|
154
|
+
for (const key of FILE_KEYS) {
|
|
155
|
+
if (typeof paths[key] !== 'string' || !paths[key].trim()) {
|
|
156
|
+
throw new TypeError(`provider delete ${key} path is required`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build explicit adapters for executeProviderDelete.
|
|
163
|
+
*
|
|
164
|
+
* `restart` is intentionally required from the caller. The Hub process must
|
|
165
|
+
* not guess how its supervisor works or restart the official 3080 process.
|
|
166
|
+
* Without a supplied supervisor the transaction fails closed and compensates
|
|
167
|
+
* any writes using the managed-file backup.
|
|
168
|
+
*/
|
|
169
|
+
export function createProviderDeleteFileHooks({
|
|
170
|
+
profileFile,
|
|
171
|
+
configFile,
|
|
172
|
+
lifecycleFile,
|
|
173
|
+
backupDir,
|
|
174
|
+
readConfig = () => defaultReadConfig(configFile),
|
|
175
|
+
writeConfig = (config) => defaultWriteConfig(configFile, config),
|
|
176
|
+
restart,
|
|
177
|
+
existingBackupId = null,
|
|
178
|
+
expectedProviderId = null,
|
|
179
|
+
fs = { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync },
|
|
180
|
+
} = {}) {
|
|
181
|
+
validatePaths({ profile: profileFile, config: configFile, lifecycle: lifecycleFile });
|
|
182
|
+
if (typeof backupDir !== 'string' || !backupDir.trim()) throw new TypeError('provider delete backup directory is required');
|
|
183
|
+
const paths = fileMap({ profileFile, configFile, lifecycleFile });
|
|
184
|
+
let activeBackup = null;
|
|
185
|
+
let lockPath = null;
|
|
186
|
+
let lockOwned = false;
|
|
187
|
+
|
|
188
|
+
const acquireLock = async () => {
|
|
189
|
+
if (lockOwned) return;
|
|
190
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
191
|
+
lockPath = join(backupDir, '.delete.lock');
|
|
192
|
+
try {
|
|
193
|
+
fs.mkdirSync(lockPath);
|
|
194
|
+
lockOwned = true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw Object.assign(new Error('another provider deletion is active'), { code: error?.code === 'EEXIST' ? 'PROVIDER_DELETE_BUSY' : 'PROVIDER_DELETE_LOCK_UNAVAILABLE' });
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (existingBackupId !== null) {
|
|
201
|
+
const backupId = safePlanId(existingBackupId);
|
|
202
|
+
if (!backupId) throw Object.assign(new Error('provider backup id is invalid'), { code: 'PROVIDER_DELETE_PLAN_INVALID' });
|
|
203
|
+
const root = join(backupDir, backupId);
|
|
204
|
+
const manifest = readJson(join(root, 'manifest.json'), null);
|
|
205
|
+
if (!manifest || manifest.schema_version !== 1 || !manifest.files || !manifest.plan || (expectedProviderId && manifest.provider_id !== expectedProviderId)) {
|
|
206
|
+
throw Object.assign(new Error('provider backup is invalid'), { code: 'PROVIDER_DELETE_BACKUP_INVALID' });
|
|
207
|
+
}
|
|
208
|
+
activeBackup = { root, manifest };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const persistManifest = () => {
|
|
212
|
+
if (!activeBackup) return;
|
|
213
|
+
atomicWrite(join(activeBackup.root, 'manifest.json'), JSON.stringify(activeBackup.manifest, null, 2) + '\n');
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const backup = async (plan) => {
|
|
217
|
+
const planId = safePlanId(plan?.plan_id);
|
|
218
|
+
if (!planId) throw Object.assign(new Error('provider delete plan id is invalid'), { code: 'PROVIDER_DELETE_PLAN_INVALID' });
|
|
219
|
+
await acquireLock();
|
|
220
|
+
const root = join(backupDir, planId);
|
|
221
|
+
fs.mkdirSync(root, { recursive: true });
|
|
222
|
+
const manifest = {
|
|
223
|
+
schema_version: 1,
|
|
224
|
+
provider_id: plan.provider_id,
|
|
225
|
+
plan: {
|
|
226
|
+
plan_id: plan.plan_id,
|
|
227
|
+
provider_id: plan.provider_id,
|
|
228
|
+
expected_revision: plan.expected_revision ?? null,
|
|
229
|
+
replacement_default: plan.replacement_default ?? null,
|
|
230
|
+
was_harness_default: plan.was_harness_default === true,
|
|
231
|
+
replacement_default_model: plan.replacement_default_model ?? null,
|
|
232
|
+
},
|
|
233
|
+
files: {},
|
|
234
|
+
};
|
|
235
|
+
for (const key of FILE_KEYS) {
|
|
236
|
+
const source = paths[key];
|
|
237
|
+
assertManagedPath(source);
|
|
238
|
+
const existed = fs.existsSync(source);
|
|
239
|
+
manifest.files[key] = { existed };
|
|
240
|
+
if (existed && key !== 'config') {
|
|
241
|
+
const target = join(root, `${key}.backup`);
|
|
242
|
+
fs.copyFileSync(source, target);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const config = defaultReadConfig(configFile);
|
|
246
|
+
const lifecycle = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
247
|
+
manifest.config_projection = configProjection(config);
|
|
248
|
+
manifest.config_revision = sha256(JSON.stringify(config));
|
|
249
|
+
manifest.lifecycle_revision = sha256(JSON.stringify(lifecycle));
|
|
250
|
+
manifest.profile_revision = sha256(fs.readFileSync(profileFile, 'utf8'));
|
|
251
|
+
activeBackup = { root, manifest };
|
|
252
|
+
persistManifest();
|
|
253
|
+
return { ok: true, backup_id: planId };
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const markTombstone = async (providerId) => {
|
|
257
|
+
if (!validProviderId(providerId)) throw Object.assign(new Error('provider id is invalid'), { code: 'PROVIDER_NOT_FOUND' });
|
|
258
|
+
assertManagedPath(lifecycleFile);
|
|
259
|
+
const state = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
260
|
+
if (activeBackup?.manifest?.lifecycle_revision && sha256(JSON.stringify(state)) !== activeBackup.manifest.lifecycle_revision) {
|
|
261
|
+
throw Object.assign(new Error('provider lifecycle changed'), { code: 'PROVIDER_LIFECYCLE_CHANGED' });
|
|
262
|
+
}
|
|
263
|
+
const marked = markProviderTombstone(state, providerId);
|
|
264
|
+
atomicWrite(lifecycleFile, JSON.stringify(marked, null, 2) + '\n');
|
|
265
|
+
if (activeBackup) {
|
|
266
|
+
activeBackup.manifest.applied_lifecycle_revision = sha256(JSON.stringify(marked));
|
|
267
|
+
persistManifest();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const scrubReferences = async (plan) => {
|
|
272
|
+
const config = await readConfig();
|
|
273
|
+
if (sha256(JSON.stringify(config)) !== activeBackup?.manifest?.config_revision) {
|
|
274
|
+
throw Object.assign(new Error('provider config changed'), { code: 'PROVIDER_CONFIG_CHANGED' });
|
|
275
|
+
}
|
|
276
|
+
const scrubbed = scrubProviderReferences(config, [plan.provider_id]);
|
|
277
|
+
if (plan.was_harness_default === true) {
|
|
278
|
+
if (!validProviderId(plan.replacement_default) || typeof plan.replacement_default_model !== 'string' || !plan.replacement_default_model.trim()) {
|
|
279
|
+
throw Object.assign(new Error('replacement Harness Default model is required'), { code: 'PROVIDER_DEFAULT_REPLACEMENT_MODEL_REQUIRED' });
|
|
280
|
+
}
|
|
281
|
+
scrubbed.config.harness_default = {
|
|
282
|
+
provider: plan.replacement_default,
|
|
283
|
+
model: plan.replacement_default_model.trim(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
await writeConfig(scrubbed.config);
|
|
287
|
+
if (activeBackup) {
|
|
288
|
+
activeBackup.manifest.applied_config_revision = sha256(JSON.stringify(scrubbed.config));
|
|
289
|
+
persistManifest();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const removeDeclarations = async (plan) => {
|
|
294
|
+
assertManagedPath(profileFile);
|
|
295
|
+
const source = fs.readFileSync(profileFile, 'utf8');
|
|
296
|
+
const result = removeProviderDeclarations(source, {
|
|
297
|
+
providerIds: [plan.provider_id],
|
|
298
|
+
expectedRevision: plan.expected_revision,
|
|
299
|
+
});
|
|
300
|
+
if (!result.ok) throw Object.assign(new Error('provider profile changed'), { code: result.code });
|
|
301
|
+
atomicWrite(profileFile, result.text);
|
|
302
|
+
if (activeBackup) {
|
|
303
|
+
activeBackup.manifest.applied_profile_revision = result.revision;
|
|
304
|
+
persistManifest();
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const checkpointApplied = async () => {
|
|
309
|
+
if (!activeBackup) throw Object.assign(new Error('provider delete backup is unavailable'), { code: 'PROVIDER_DELETE_BACKUP_INVALID' });
|
|
310
|
+
const config = await readConfig();
|
|
311
|
+
const lifecycle = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
312
|
+
activeBackup.manifest.applied_config_revision = sha256(JSON.stringify(config));
|
|
313
|
+
activeBackup.manifest.applied_lifecycle_revision = sha256(JSON.stringify(lifecycle));
|
|
314
|
+
activeBackup.manifest.applied_profile_revision = sha256(fs.readFileSync(profileFile, 'utf8'));
|
|
315
|
+
persistManifest();
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const backupPlan = () => (activeBackup?.manifest?.plan ? { ...activeBackup.manifest.plan } : null);
|
|
319
|
+
|
|
320
|
+
const verify = async (plan) => {
|
|
321
|
+
assertManagedPath(profileFile);
|
|
322
|
+
assertManagedPath(lifecycleFile);
|
|
323
|
+
const source = fs.readFileSync(profileFile, 'utf8');
|
|
324
|
+
const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
|
|
325
|
+
const providerAbsent = parsed.ok
|
|
326
|
+
? !parsed.declarations.some((declaration) => declaration.id === plan.provider_id)
|
|
327
|
+
: !profileContainsProvider(source, plan.provider_id);
|
|
328
|
+
const config = await readConfig();
|
|
329
|
+
const routingClear = scrubProviderReferences(config, [plan.provider_id]).removed.length === 0;
|
|
330
|
+
const replacementApplied = plan.was_harness_default !== true || (
|
|
331
|
+
config?.harness_default?.provider === plan.replacement_default
|
|
332
|
+
&& config?.harness_default?.model === plan.replacement_default_model
|
|
333
|
+
);
|
|
334
|
+
const state = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
335
|
+
return {
|
|
336
|
+
providerAbsent,
|
|
337
|
+
routingClear: routingClear && replacementApplied,
|
|
338
|
+
tombstonePresent: state.tombstones[plan.provider_id] === 'absent',
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const rollback = async () => {
|
|
343
|
+
if (!activeBackup) throw Object.assign(new Error('provider delete backup is unavailable'), { code: 'PROVIDER_DELETE_ROLLBACK_UNAVAILABLE' });
|
|
344
|
+
const allowedRevision = (current, original, applied) => current === original || (typeof applied === 'string' && current === applied);
|
|
345
|
+
const currentConfig = await readConfig();
|
|
346
|
+
const currentLifecycle = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
347
|
+
const currentProfile = fs.readFileSync(profileFile, 'utf8');
|
|
348
|
+
if (!allowedRevision(sha256(JSON.stringify(currentConfig)), activeBackup.manifest.config_revision, activeBackup.manifest.applied_config_revision)
|
|
349
|
+
|| !allowedRevision(sha256(JSON.stringify(currentLifecycle)), activeBackup.manifest.lifecycle_revision, activeBackup.manifest.applied_lifecycle_revision)
|
|
350
|
+
|| !allowedRevision(sha256(currentProfile), activeBackup.manifest.profile_revision, activeBackup.manifest.applied_profile_revision)) {
|
|
351
|
+
throw Object.assign(new Error('managed provider state changed after the transaction'), { code: 'PROVIDER_DELETE_STATE_CHANGED' });
|
|
352
|
+
}
|
|
353
|
+
for (const key of FILE_KEYS) {
|
|
354
|
+
const target = paths[key];
|
|
355
|
+
const entry = activeBackup.manifest.files[key];
|
|
356
|
+
const backupFile = join(activeBackup.root, `${key}.backup`);
|
|
357
|
+
assertManagedPath(target);
|
|
358
|
+
if (key === 'config') {
|
|
359
|
+
if (entry?.existed === true) {
|
|
360
|
+
const restored = restoreConfigProjection(await readConfig(), activeBackup.manifest.config_projection);
|
|
361
|
+
await writeConfig(restored);
|
|
362
|
+
} else {
|
|
363
|
+
fs.rmSync(target, { force: true });
|
|
364
|
+
}
|
|
365
|
+
} else if (entry?.existed === true) {
|
|
366
|
+
safeRestoreFile(backupFile, target);
|
|
367
|
+
} else {
|
|
368
|
+
fs.rmSync(target, { force: true });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const release = async () => {
|
|
374
|
+
if (!lockOwned || !lockPath) return;
|
|
375
|
+
try { fs.rmSync(lockPath, { recursive: true, force: true }); } finally { lockOwned = false; lockPath = null; }
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const verifyRollback = async (plan) => {
|
|
379
|
+
assertManagedPath(profileFile);
|
|
380
|
+
assertManagedPath(lifecycleFile);
|
|
381
|
+
const source = fs.readFileSync(profileFile, 'utf8');
|
|
382
|
+
const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
|
|
383
|
+
const providerPresent = parsed.ok && parsed.declarations.some((declaration) => declaration.id === plan.provider_id);
|
|
384
|
+
const state = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
385
|
+
return { ok: providerPresent && state.tombstones[plan.provider_id] !== 'absent' };
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const recordTransaction = async (result, plan) => {
|
|
389
|
+
assertManagedPath(lifecycleFile);
|
|
390
|
+
const state = normalizeProviderLifecycleState(readJson(lifecycleFile, {}));
|
|
391
|
+
const next = recordProviderTransaction(state, {
|
|
392
|
+
transaction_id: result?.transaction_id ?? plan?.plan_id,
|
|
393
|
+
provider_id: result?.provider_id ?? plan?.provider_id,
|
|
394
|
+
state: result?.state,
|
|
395
|
+
expected_revision: plan?.expected_revision,
|
|
396
|
+
});
|
|
397
|
+
if (result?.state === 'VERIFIED') {
|
|
398
|
+
assertManagedPath(profileFile);
|
|
399
|
+
next.last_verified_revision[plan.provider_id] = sha256(fs.readFileSync(profileFile, 'utf8'));
|
|
400
|
+
}
|
|
401
|
+
atomicWrite(lifecycleFile, JSON.stringify(next, null, 2) + '\n');
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
backup,
|
|
406
|
+
acquireLock,
|
|
407
|
+
checkpointApplied,
|
|
408
|
+
backupPlan,
|
|
409
|
+
markTombstone,
|
|
410
|
+
scrubReferences,
|
|
411
|
+
removeDeclarations,
|
|
412
|
+
restart: typeof restart === 'function'
|
|
413
|
+
? async (plan) => restart(plan)
|
|
414
|
+
: async () => ({ ok: false, code: 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' }),
|
|
415
|
+
restartRollback: typeof restart === 'function'
|
|
416
|
+
? async (plan) => restart(plan)
|
|
417
|
+
: async () => ({ ok: false, code: 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' }),
|
|
418
|
+
verify,
|
|
419
|
+
verifyRollback,
|
|
420
|
+
recordTransaction,
|
|
421
|
+
rollback,
|
|
422
|
+
release,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Bounded provider/model callability evidence.
|
|
2
|
+
//
|
|
3
|
+
// Catalog presence is not proof of a callable route. This store keeps only
|
|
4
|
+
// sanitized state and timestamps; raw provider errors never leave the caller.
|
|
5
|
+
|
|
6
|
+
export const PROVIDER_HEALTH_STATES = Object.freeze([
|
|
7
|
+
'callable', 'credential-missing', 'quota-exhausted', 'rate-limited',
|
|
8
|
+
'timeout', 'internal-error', 'disabled', 'not-configured', 'tombstoned', 'unprobed',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const REASON_CODES = Object.freeze({
|
|
12
|
+
callable: 'PROVIDER_CALLABLE',
|
|
13
|
+
'credential-missing': 'CREDENTIAL_MISSING',
|
|
14
|
+
'quota-exhausted': 'QUOTA_EXHAUSTED',
|
|
15
|
+
'rate-limited': 'RATE_LIMITED',
|
|
16
|
+
timeout: 'PROBE_TIMEOUT',
|
|
17
|
+
'internal-error': 'PROVIDER_INTERNAL_ERROR',
|
|
18
|
+
disabled: 'PROVIDER_DISABLED',
|
|
19
|
+
'not-configured': 'PROVIDER_NOT_CONFIGURED',
|
|
20
|
+
tombstoned: 'PROVIDER_TOMBSTONED',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TTLS = Object.freeze({
|
|
24
|
+
callable: 5 * 60 * 1000,
|
|
25
|
+
'credential-missing': 30 * 1000,
|
|
26
|
+
'quota-exhausted': 15 * 60 * 1000,
|
|
27
|
+
'rate-limited': 60 * 1000,
|
|
28
|
+
timeout: 60 * 1000,
|
|
29
|
+
'internal-error': 60 * 1000,
|
|
30
|
+
disabled: 5 * 60 * 1000,
|
|
31
|
+
'not-configured': 30 * 1000,
|
|
32
|
+
tombstoned: 5 * 60 * 1000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function text(value) {
|
|
36
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function providerKey(provider, model) {
|
|
40
|
+
const p = text(provider);
|
|
41
|
+
const m = text(model);
|
|
42
|
+
return p && m ? `${p}\u0000${m}` : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorText(error) {
|
|
46
|
+
if (!error || typeof error !== 'object') return '';
|
|
47
|
+
return [error.code, error.name, error.message, error.status].filter((value) => value !== undefined && value !== null).join(' ').toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Map an upstream error to one bounded, secret-free callability state. */
|
|
51
|
+
export function classifyProviderError(error = {}) {
|
|
52
|
+
const textValue = errorText(error);
|
|
53
|
+
const code = text(error?.code)?.toUpperCase() ?? '';
|
|
54
|
+
if (code === 'MISSING_CREDENTIAL' || code === 'CREDENTIAL_MISSING' || /no (api )?key|credential.*missing|missing.*credential/.test(textValue)) {
|
|
55
|
+
return { state: 'credential-missing', reason_code: REASON_CODES['credential-missing'] };
|
|
56
|
+
}
|
|
57
|
+
if (code === 'QUOTA_EXCEEDED' || /monthly|account/.test(textValue) && /quota|exhaust|limit/.test(textValue)) {
|
|
58
|
+
return { state: 'quota-exhausted', reason_code: REASON_CODES['quota-exhausted'] };
|
|
59
|
+
}
|
|
60
|
+
if (Number(error?.status) === 429 || code === 'RATE_LIMITED' || /rate.?limit|too many requests/.test(textValue)) {
|
|
61
|
+
return { state: 'rate-limited', reason_code: REASON_CODES['rate-limited'] };
|
|
62
|
+
}
|
|
63
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || code === 'TIMEOUT' || /timed? out|timeout|aborted/.test(textValue)) {
|
|
64
|
+
return { state: 'timeout', reason_code: REASON_CODES.timeout };
|
|
65
|
+
}
|
|
66
|
+
return { state: 'internal-error', reason_code: REASON_CODES['internal-error'] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function boundedTimestamp(value, fallback) {
|
|
70
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function view(entry, now) {
|
|
74
|
+
if (!entry) return { state: 'unprobed', reason_code: null, observed_at: null, expires_at: null, fresh: false };
|
|
75
|
+
const fresh = now < entry.expires_at;
|
|
76
|
+
if (!fresh) return { state: 'unprobed', reason_code: null, observed_at: entry.observed_at, expires_at: entry.expires_at, fresh: false };
|
|
77
|
+
return { ...entry, fresh: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createProviderHealthStore({ clock = () => Date.now(), ttls = {}, maxEntries = 256 } = {}) {
|
|
81
|
+
const records = new Map();
|
|
82
|
+
const limits = { ...DEFAULT_TTLS };
|
|
83
|
+
for (const state of PROVIDER_HEALTH_STATES) {
|
|
84
|
+
if (Number.isFinite(ttls[state]) && ttls[state] >= 0) limits[state] = Math.min(ttls[state], 24 * 60 * 60 * 1000);
|
|
85
|
+
}
|
|
86
|
+
const boundedMax = Number.isInteger(maxEntries) && maxEntries > 0 ? Math.min(maxEntries, 2048) : 256;
|
|
87
|
+
|
|
88
|
+
const get = (provider, model) => {
|
|
89
|
+
const key = providerKey(provider, model);
|
|
90
|
+
if (!key) return { provider: text(provider), model: text(model), state: 'unprobed', reason_code: null, observed_at: null, expires_at: null, fresh: false };
|
|
91
|
+
const entry = records.get(key);
|
|
92
|
+
const current = view(entry, clock());
|
|
93
|
+
if (entry && !current.fresh) records.delete(key);
|
|
94
|
+
return { provider: text(provider), model: text(model), ...current };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const record = (provider, model, result = {}) => {
|
|
98
|
+
const key = providerKey(provider, model);
|
|
99
|
+
if (!key) return null;
|
|
100
|
+
const now = clock();
|
|
101
|
+
const observed = boundedTimestamp(result.observed_at, now);
|
|
102
|
+
const previous = records.get(key);
|
|
103
|
+
if (previous && previous.observed_at > observed) return get(provider, model);
|
|
104
|
+
let classified;
|
|
105
|
+
if (result.ok === true) classified = { state: 'callable', reason_code: REASON_CODES.callable };
|
|
106
|
+
else if (PROVIDER_HEALTH_STATES.includes(result.state) && result.state !== 'unprobed') classified = { state: result.state, reason_code: REASON_CODES[result.state] ?? null };
|
|
107
|
+
else classified = classifyProviderError(result.error ?? result);
|
|
108
|
+
const expires = observed + limits[classified.state];
|
|
109
|
+
records.set(key, {
|
|
110
|
+
provider: text(provider), model: text(model), ...classified,
|
|
111
|
+
observed_at: observed, expires_at: expires,
|
|
112
|
+
});
|
|
113
|
+
while (records.size > boundedMax) {
|
|
114
|
+
const oldest = [...records.entries()].sort((a, b) => a[1].observed_at - b[1].observed_at)[0]?.[0];
|
|
115
|
+
if (!oldest) break;
|
|
116
|
+
records.delete(oldest);
|
|
117
|
+
}
|
|
118
|
+
return get(provider, model);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const list = () => [...records.values()].map((entry) => view(entry, clock())).filter((entry) => entry.fresh).slice(0, boundedMax);
|
|
122
|
+
|
|
123
|
+
return { get, record, list, ttls: { ...limits }, maxEntries: boundedMax };
|
|
124
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Secret-free inventory projection for Harness providers.
|
|
2
|
+
//
|
|
3
|
+
// Harness owns the live provider/model catalog; Crew owns the routing policy
|
|
4
|
+
// and (where explicitly marked) profile provenance. This module only joins
|
|
5
|
+
// those observations into a bounded record set for lifecycle/readiness UIs.
|
|
6
|
+
|
|
7
|
+
const LIFECYCLE_ORIGINS = new Set(['builtin', 'profile-managed', 'dynamic', 'unknown']);
|
|
8
|
+
const OWNERSHIPS = new Set(['harness', 'crew-managed-profile', 'user-managed-profile', 'dynamic-user', 'unknown']);
|
|
9
|
+
|
|
10
|
+
function text(value) {
|
|
11
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeProviderId(value) {
|
|
15
|
+
return text(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeCredentialRefs(declaration) {
|
|
19
|
+
const raw = declaration?.credential_refs ?? declaration?.credential_ref;
|
|
20
|
+
const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const refs = [];
|
|
23
|
+
for (const candidate of values) {
|
|
24
|
+
const name = text(typeof candidate === 'object' ? candidate.name_or_handle ?? candidate.name : candidate);
|
|
25
|
+
if (!name || seen.has(name)) continue;
|
|
26
|
+
seen.add(name);
|
|
27
|
+
const kind = text(typeof candidate === 'object' ? candidate.kind : null) ?? 'env';
|
|
28
|
+
const ownership = text(typeof candidate === 'object' ? candidate.ownership : null) ?? 'crew';
|
|
29
|
+
refs.push({
|
|
30
|
+
kind: ['env', 'crew-store', 'harness-store', 'unknown'].includes(kind) ? kind : 'unknown',
|
|
31
|
+
name_or_handle: name,
|
|
32
|
+
ownership: ['crew', 'user', 'external', 'unknown'].includes(ownership) ? ownership : 'unknown',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return refs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findPriorityIndex(policy, key, providerId) {
|
|
39
|
+
const list = Array.isArray(policy?.[key]) ? policy[key] : [];
|
|
40
|
+
const index = list.findIndex((entry) => normalizeProviderId(entry?.provider) === providerId);
|
|
41
|
+
return index >= 0 ? index : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function declarationFor(declarations, id) {
|
|
45
|
+
return declarations.find((entry) => normalizeProviderId(entry?.id) === id) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function catalogFor(providers, id) {
|
|
49
|
+
return providers.find((entry) => normalizeProviderId(entry?.id) === id) ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeOrigin(value) {
|
|
53
|
+
const origin = text(value) ?? 'unknown';
|
|
54
|
+
return LIFECYCLE_ORIGINS.has(origin) ? origin : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeOwnership(value) {
|
|
58
|
+
const ownership = text(value) ?? 'unknown';
|
|
59
|
+
return OWNERSHIPS.has(ownership) ? ownership : 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function modelIds(provider) {
|
|
63
|
+
if (!Array.isArray(provider?.models)) return [];
|
|
64
|
+
return [...new Set(provider.models.map((model) => text(typeof model === 'object' ? model?.id : model)).filter(Boolean))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a secret-free provider inventory from live Harness and Crew-owned
|
|
69
|
+
* observations. No credential value is accepted or copied into the result.
|
|
70
|
+
*/
|
|
71
|
+
export function buildProviderInventory({ catalog = {}, declarations = [], policy = {}, tombstones = {}, activeJobs = [], multimodalRefs = {} } = {}) {
|
|
72
|
+
const catalogProviders = Array.isArray(catalog?.providers) ? catalog.providers : [];
|
|
73
|
+
const safeDeclarations = Array.isArray(declarations) ? declarations : [];
|
|
74
|
+
const ids = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const source of [catalogProviders, safeDeclarations]) {
|
|
77
|
+
for (const entry of source) {
|
|
78
|
+
const id = normalizeProviderId(entry?.id);
|
|
79
|
+
if (!id || seen.has(id)) continue;
|
|
80
|
+
seen.add(id);
|
|
81
|
+
ids.push(id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const harnessDefault = normalizeProviderId(catalog?.harness_default?.provider);
|
|
86
|
+
const records = ids.map((id) => {
|
|
87
|
+
const catalogEntry = catalogFor(catalogProviders, id);
|
|
88
|
+
const declaration = declarationFor(safeDeclarations, id);
|
|
89
|
+
const declared = declaration !== null;
|
|
90
|
+
const catalogued = catalogEntry !== null;
|
|
91
|
+
const tombstoned = tombstones?.[id] === 'absent';
|
|
92
|
+
const configured = declared;
|
|
93
|
+
const active = Array.isArray(activeJobs)
|
|
94
|
+
? activeJobs.filter((job) => normalizeProviderId(job?.provider) === id).length
|
|
95
|
+
: Number.isInteger(activeJobs?.[id]) ? Math.max(0, activeJobs[id]) : 0;
|
|
96
|
+
const multimodal = Array.isArray(multimodalRefs?.[id]) ? multimodalRefs[id].length : Number.isInteger(multimodalRefs?.[id]) ? Math.max(0, multimodalRefs[id]) : 0;
|
|
97
|
+
const declarationFile = text(declaration?.file);
|
|
98
|
+
const locator = text(declaration?.locator);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
display_name: text(declaration?.display_name) ?? text(declaration?.name) ?? text(catalogEntry?.name) ?? id,
|
|
103
|
+
origin: normalizeOrigin(declaration?.origin ?? (declared ? 'profile-managed' : 'builtin')),
|
|
104
|
+
ownership: normalizeOwnership(declaration?.ownership ?? (declared ? 'crew-managed-profile' : 'harness')),
|
|
105
|
+
declaration: {
|
|
106
|
+
...(declarationFile ? { file: declarationFile } : {}),
|
|
107
|
+
...(locator ? { locator } : {}),
|
|
108
|
+
present: declared,
|
|
109
|
+
},
|
|
110
|
+
models: modelIds(catalogEntry),
|
|
111
|
+
lifecycle: {
|
|
112
|
+
installed: declared || catalogued,
|
|
113
|
+
configured,
|
|
114
|
+
enabled: !tombstoned && declaration?.enabled !== false,
|
|
115
|
+
catalogued,
|
|
116
|
+
},
|
|
117
|
+
credential_refs: normalizeCredentialRefs(declaration),
|
|
118
|
+
references: {
|
|
119
|
+
harness_default: harnessDefault === id,
|
|
120
|
+
worker_priority: findPriorityIndex(policy?.worker, 'priority', id),
|
|
121
|
+
worker_escalation: findPriorityIndex(policy?.worker, 'escalation_priority', id),
|
|
122
|
+
reviewer_priority: findPriorityIndex(policy?.reviewer ?? policy?.review, 'priority', id),
|
|
123
|
+
active_jobs: active,
|
|
124
|
+
multimodal_refs: multimodal,
|
|
125
|
+
},
|
|
126
|
+
desired_state: tombstoned ? 'absent' : 'present',
|
|
127
|
+
activation: declared ? 'restart-required' : 'live',
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return { schema_version: 1, records };
|
|
132
|
+
}
|