@ran-sh/dsh-crew 1.0.1 → 1.0.2
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 +24 -20
- package/README.zh.md +21 -18
- package/lib/client.js +156 -13
- package/official-web-bridge/lib/client.js +156 -13
- package/package.json +166 -166
- package/src/client/index.tsx +765 -716
- package/src/hub/index.mjs +418 -62
- package/src/install/install-legacy.mjs +184 -185
- package/src/policy-legacy.mjs +2 -2
- package/src/provider-delete-adapters.mjs +1786 -451
- package/src/provider-inventory.mjs +170 -132
- package/src/provider-lifecycle-state.mjs +100 -95
- package/src/provider-lifecycle.mjs +252 -214
- package/src/provider-profile-store.mjs +189 -171
- package/src/provider-settings-store.mjs +215 -0
- package/src/runtime-identity.mjs +72 -72
package/src/hub/index.mjs
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Web UI session list), exposes a loopback jobs API for the CC/Codex MCP shim,
|
|
4
4
|
// and serves the one-click installer endpoints for the settings page.
|
|
5
5
|
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { join, isAbsolute } from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
|
|
11
11
|
import {
|
|
@@ -35,10 +35,11 @@ import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
|
|
|
35
35
|
import { raceWaiters } from '../removable-waiter.mjs';
|
|
36
36
|
import { buildProviderInventory } from '../provider-inventory.mjs';
|
|
37
37
|
import { inspectProviderProfile, readProviderDeclarations } from '../provider-profile-store.mjs';
|
|
38
|
+
import { inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations } from '../provider-settings-store.mjs';
|
|
38
39
|
import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
|
|
39
40
|
import { createProviderHealthStore } from '../provider-health.mjs';
|
|
40
41
|
import { planProviderDelete } from '../provider-lifecycle.mjs';
|
|
41
|
-
import { createProviderDeleteFileHooks } from '../provider-delete-adapters.mjs';
|
|
42
|
+
import { createProviderDeleteFileHooks, isRecoverableProviderDeleteBackup, readProviderDeleteManifestFile } from '../provider-delete-adapters.mjs';
|
|
42
43
|
import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
|
|
43
44
|
import { executeCredentialPurge, planCredentialPurge } from '../credential-lifecycle.mjs';
|
|
44
45
|
import { createCredentialPurgeFileHooks } from '../credential-purge-adapters.mjs';
|
|
@@ -106,19 +107,100 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
|
|
|
106
107
|
const ROLES = { worker: true, reviewer: true };
|
|
107
108
|
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
108
109
|
const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
|
|
110
|
+
const RECOVERY_ACTION_PATTERN = /^[a-f0-9]{32}$/u;
|
|
109
111
|
|
|
110
|
-
function
|
|
112
|
+
function recoveryActionId(name) {
|
|
113
|
+
return createHash('sha256').update(String(name)).digest('hex').slice(0, 32);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRecoveryControlEntry(name) {
|
|
117
|
+
return name === '.quarantine'
|
|
118
|
+
|| name === '.delete.lock'
|
|
119
|
+
|| name === '.delete.reclaim.lock'
|
|
120
|
+
|| name === '.delete.recovery.lock'
|
|
121
|
+
|| name === '.delete.recovery.lock.claim'
|
|
122
|
+
|| /^\.delete\.lock\.[0-9a-f-]+\.(?:active|staging|retired)$/iu.test(name)
|
|
123
|
+
|| /^\.delete\.recovery\.lock\.[0-9a-f-]+\.stale$/iu.test(name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readProviderLifecycleStateStatus() {
|
|
127
|
+
const file = join(CONFIG_DIR, 'provider-lifecycle.json');
|
|
128
|
+
if (!existsSync(file)) return { ok: true, state: normalizeProviderLifecycleState() };
|
|
111
129
|
try {
|
|
112
|
-
return normalizeProviderLifecycleState(JSON.parse(readFileSync(
|
|
130
|
+
return { ok: true, state: normalizeProviderLifecycleState(JSON.parse(readFileSync(file, 'utf8'))) };
|
|
113
131
|
} catch {
|
|
114
|
-
return normalizeProviderLifecycleState();
|
|
132
|
+
return { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE', state: normalizeProviderLifecycleState() };
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
|
|
136
|
+
function readProviderLifecycleState() {
|
|
137
|
+
return readProviderLifecycleStateStatus().state;
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
function readCredentialPurgeState() {
|
|
119
141
|
try { return normalizeCredentialPurgeState(JSON.parse(readFileSync(CREDENTIAL_PURGE_STATE_FILE, 'utf8'))); } catch { return normalizeCredentialPurgeState(); }
|
|
120
142
|
}
|
|
121
143
|
|
|
144
|
+
export function readProviderRecoveryTransactions(root = join(CONFIG_DIR, 'provider-backups')) {
|
|
145
|
+
if (!existsSync(root)) return [];
|
|
146
|
+
try {
|
|
147
|
+
const managedRoot = dirname(resolvePath(root));
|
|
148
|
+
const paths = {
|
|
149
|
+
profile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
150
|
+
settings: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
151
|
+
config: join(CONFIG_DIR, 'config.json'),
|
|
152
|
+
lifecycle: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
153
|
+
};
|
|
154
|
+
const transactions = readdirSync(root, { withFileTypes: true }).filter((entry) => !isRecoveryControlEntry(entry.name)).flatMap((entry) => {
|
|
155
|
+
const transactionRoot = join(root, entry.name);
|
|
156
|
+
const manifestFile = join(root, entry.name, 'manifest.json');
|
|
157
|
+
const actionId = recoveryActionId(entry.name);
|
|
158
|
+
if (!entry.isDirectory()) {
|
|
159
|
+
let updatedAt;
|
|
160
|
+
try { updatedAt = statSync(transactionRoot).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
|
|
161
|
+
return [{ storage_id: entry.name, action_id: actionId, transaction_id: null, provider_id: null, phase: 'RECOVERY_UNRESOLVED', updated_at: updatedAt, recoverable: false, unresolved: true }];
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const manifest = readProviderDeleteManifestFile(manifestFile, managedRoot);
|
|
165
|
+
const transactionId = typeof manifest?.plan?.plan_id === 'string' ? manifest.plan.plan_id : null;
|
|
166
|
+
const providerId = typeof manifest?.provider_id === 'string' ? manifest.provider_id : null;
|
|
167
|
+
const phase = typeof manifest?.phase_journal?.phase === 'string' && manifest.phase_journal.phase.trim()
|
|
168
|
+
? manifest.phase_journal.phase.trim() : 'RECOVERY_INCOMPLETE';
|
|
169
|
+
const updatedAt = typeof manifest?.phase_journal?.updated_at === 'string' && manifest.phase_journal.updated_at.trim()
|
|
170
|
+
? manifest.phase_journal.updated_at : statSync(manifestFile).mtime.toISOString();
|
|
171
|
+
const hasPendingMutation = manifest?.mutation_journal && typeof manifest.mutation_journal === 'object'
|
|
172
|
+
&& Object.keys(manifest.mutation_journal).length > 0;
|
|
173
|
+
const recoverable = transactionId != null && providerId != null && isRecoverableProviderDeleteBackup(manifest, {
|
|
174
|
+
root: transactionRoot,
|
|
175
|
+
managedRoot,
|
|
176
|
+
paths,
|
|
177
|
+
expectedProviderId: providerId,
|
|
178
|
+
}) === true;
|
|
179
|
+
if (['ROLLED_BACK', 'VERIFIED'].includes(phase) && !hasPendingMutation && recoverable) return [];
|
|
180
|
+
return [{ storage_id: entry.name, action_id: actionId, transaction_id: transactionId ?? entry.name, provider_id: providerId, phase, updated_at: updatedAt, recoverable, unresolved: recoverable !== true }];
|
|
181
|
+
} catch {
|
|
182
|
+
let updatedAt;
|
|
183
|
+
try { updatedAt = statSync(transactionRoot).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
|
|
184
|
+
return [{
|
|
185
|
+
storage_id: entry.name,
|
|
186
|
+
action_id: actionId,
|
|
187
|
+
transaction_id: null,
|
|
188
|
+
provider_id: null,
|
|
189
|
+
phase: 'RECOVERY_UNRESOLVED',
|
|
190
|
+
updated_at: updatedAt,
|
|
191
|
+
recoverable: false,
|
|
192
|
+
unresolved: true,
|
|
193
|
+
}];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return transactions.sort((a, b) => a.updated_at.localeCompare(b.updated_at)).slice(-64);
|
|
197
|
+
} catch {
|
|
198
|
+
let updatedAt;
|
|
199
|
+
try { updatedAt = statSync(root).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
|
|
200
|
+
return [{ storage_id: null, action_id: null, transaction_id: null, provider_id: null, phase: 'RECOVERY_UNRESOLVED', updated_at: updatedAt, recoverable: false, unresolved: true }];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
122
204
|
function writeCredentialPurgeState(state) {
|
|
123
205
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
124
206
|
const temp = `${CREDENTIAL_PURGE_STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
@@ -144,34 +226,114 @@ function providerInventoryPolicy(config) {
|
|
|
144
226
|
};
|
|
145
227
|
}
|
|
146
228
|
|
|
229
|
+
export function hasCompleteProviderCatalogEvidence(evidence) {
|
|
230
|
+
return evidence?.ok === true && evidence.partial !== true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function hasCompleteProviderDeclarationEvidence(evidence) {
|
|
234
|
+
return evidence?.ok === true
|
|
235
|
+
&& Object.values(evidence.sources ?? {}).every((source) => source?.present !== true || !source.code);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function hasAvailableProviderLifecycleEvidence(evidence) {
|
|
239
|
+
return evidence?.ok === true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function hasPendingProviderRecoveryTransactions(inventory) {
|
|
243
|
+
return Array.isArray(inventory?.recovery_transactions) && inventory.recovery_transactions.length > 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function hasProviderRuntimeRestartEvidence(beforeRuntimeId, currentRuntimeId) {
|
|
247
|
+
return typeof beforeRuntimeId === 'string' && beforeRuntimeId.trim() !== ''
|
|
248
|
+
&& typeof currentRuntimeId === 'string' && currentRuntimeId.trim() !== ''
|
|
249
|
+
&& beforeRuntimeId !== currentRuntimeId;
|
|
250
|
+
}
|
|
251
|
+
|
|
147
252
|
async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
148
253
|
let catalog = { providers: [], harness_default: null };
|
|
254
|
+
let catalogEvidence = { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' };
|
|
149
255
|
try {
|
|
150
256
|
catalog = await readHarnessModelCatalog({
|
|
151
257
|
llm: ctx.llm ?? ctx.get('llm'),
|
|
152
258
|
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
153
259
|
});
|
|
260
|
+
catalogEvidence = {
|
|
261
|
+
ok: catalog.partial !== true,
|
|
262
|
+
...(catalog.partial === true ? { code: 'PROVIDER_CATALOG_INCOMPLETE' } : {}),
|
|
263
|
+
provider_count: Number.isInteger(catalog.provider_count) ? catalog.provider_count : catalog.providers.length,
|
|
264
|
+
refreshed_at: typeof catalog.refreshed_at === 'string' ? catalog.refreshed_at : null,
|
|
265
|
+
partial: catalog.partial === true,
|
|
266
|
+
};
|
|
154
267
|
} catch {}
|
|
155
268
|
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
156
269
|
let declarations = [];
|
|
270
|
+
const declarationEvidence = { ok: true, sources: {} };
|
|
157
271
|
try {
|
|
272
|
+
declarationEvidence.sources.profile = { present: existsSync(profileFile) };
|
|
158
273
|
if (existsSync(profileFile)) {
|
|
159
274
|
const source = readFileSync(profileFile, 'utf8');
|
|
160
275
|
const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
|
|
161
276
|
if (parsed.ok) declarations = parsed.declarations;
|
|
277
|
+
else { declarationEvidence.ok = false; declarationEvidence.sources.profile.code = parsed.code; }
|
|
162
278
|
}
|
|
163
|
-
} catch {}
|
|
164
|
-
const
|
|
279
|
+
} catch { declarationEvidence.ok = false; declarationEvidence.sources.profile = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' }; }
|
|
280
|
+
const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
|
|
281
|
+
let settingsDefault = null;
|
|
282
|
+
let defaultEvidence = { ok: true, source: 'none' };
|
|
283
|
+
try {
|
|
284
|
+
declarationEvidence.sources.settings = { present: existsSync(settingsFile) };
|
|
285
|
+
if (existsSync(settingsFile)) {
|
|
286
|
+
const source = readFileSync(settingsFile, 'utf8');
|
|
287
|
+
const parsed = readProviderSettingsDeclarations(source, { file: 'harness/settings.yaml' });
|
|
288
|
+
if (parsed.ok) declarations = [...declarations, ...parsed.declarations];
|
|
289
|
+
else { declarationEvidence.ok = false; declarationEvidence.sources.settings.code = parsed.code; }
|
|
290
|
+
const parsedDefault = readHarnessDefault(source);
|
|
291
|
+
if (parsedDefault.ok) settingsDefault = parsedDefault;
|
|
292
|
+
else defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
declarationEvidence.ok = false;
|
|
296
|
+
declarationEvidence.sources.settings = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' };
|
|
297
|
+
defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
|
|
298
|
+
}
|
|
299
|
+
const liveDefault = catalog.harness_default && typeof catalog.harness_default.provider === 'string' && typeof catalog.harness_default.model === 'string'
|
|
300
|
+
? { provider: catalog.harness_default.provider, model: catalog.harness_default.model } : null;
|
|
301
|
+
const persistedDefault = settingsDefault ? { provider: settingsDefault.provider, model: settingsDefault.model } : null;
|
|
302
|
+
if (liveDefault || persistedDefault) {
|
|
303
|
+
if (!liveDefault || !persistedDefault) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
|
|
304
|
+
else if (liveDefault.provider !== persistedDefault.provider || liveDefault.model !== persistedDefault.model) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
|
|
305
|
+
}
|
|
306
|
+
// The persisted agent-default-model is the mutation authority. Keep it in
|
|
307
|
+
// the inventory even when the live selection is stale; the evidence gate
|
|
308
|
+
// above prevents destructive operations until both sources agree.
|
|
309
|
+
if (persistedDefault) {
|
|
310
|
+
catalog = {
|
|
311
|
+
...catalog,
|
|
312
|
+
harness_default: persistedDefault,
|
|
313
|
+
harness_default_authority: { kind: 'harness-settings', locator: settingsDefault.locator },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const lifecycleEvidence = readProviderLifecycleStateStatus();
|
|
317
|
+
const lifecycle = lifecycleEvidence.state;
|
|
318
|
+
const recoveryTransactions = readProviderRecoveryTransactions();
|
|
165
319
|
const credentialPurge = readCredentialPurgeState();
|
|
166
320
|
const inventory = buildProviderInventory({
|
|
167
321
|
catalog,
|
|
168
322
|
declarations,
|
|
169
323
|
policy: providerInventoryPolicy(config),
|
|
170
324
|
tombstones: lifecycle.tombstones,
|
|
325
|
+
additionalProviderIds: [
|
|
326
|
+
...Object.keys(lifecycle.tombstones ?? {}),
|
|
327
|
+
...recoveryTransactions.map((entry) => entry.provider_id).filter((id) => typeof id === 'string' && id.trim()),
|
|
328
|
+
],
|
|
171
329
|
activeJobs: hub.list(),
|
|
172
330
|
});
|
|
173
331
|
return {
|
|
174
332
|
...inventory,
|
|
333
|
+
catalog_evidence: catalogEvidence,
|
|
334
|
+
declaration_evidence: declarationEvidence,
|
|
335
|
+
default_evidence: defaultEvidence,
|
|
336
|
+
lifecycle_evidence: lifecycleEvidence.ok ? { ok: true } : { ok: false, code: lifecycleEvidence.code },
|
|
175
337
|
credential_history_refs: Object.values(lifecycle.transactions ?? {})
|
|
176
338
|
.flatMap((transaction) => Array.isArray(transaction?.credential_refs) ? transaction.credential_refs : []),
|
|
177
339
|
credential_purged_refs: Object.keys(credentialPurge.purged),
|
|
@@ -184,23 +346,27 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
|
184
346
|
transaction_id: transactionId,
|
|
185
347
|
provider_id: transaction.provider_id,
|
|
186
348
|
state: transaction.state,
|
|
349
|
+
...(transaction.updated_at ? { updated_at: transaction.updated_at } : {}),
|
|
187
350
|
...(transaction.expected_revision ? { expected_revision: transaction.expected_revision } : {}),
|
|
188
351
|
})),
|
|
352
|
+
recovery_transactions: recoveryTransactions,
|
|
189
353
|
};
|
|
190
354
|
}
|
|
191
355
|
|
|
192
|
-
function
|
|
356
|
+
function readProviderSourceRevisions() {
|
|
193
357
|
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
358
|
+
const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
|
|
359
|
+
const read = (file, inspect) => {
|
|
360
|
+
try {
|
|
361
|
+
if (!existsSync(file)) return { ok: false, code: 'PROVIDER_SOURCE_MISSING', revision: null };
|
|
362
|
+
const parsed = inspect(readFileSync(file, 'utf8'));
|
|
363
|
+
return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code, revision: parsed.revision ?? null };
|
|
364
|
+
} catch { return { ok: false, code: 'PROVIDER_SOURCE_UNAVAILABLE', revision: null }; }
|
|
365
|
+
};
|
|
366
|
+
return { profile: read(profileFile, inspectProviderProfile), settings: read(settingsFile, inspectProviderSettings) };
|
|
201
367
|
}
|
|
202
|
-
|
|
203
|
-
export function hubCanonicalEvents(job = {}) {
|
|
368
|
+
|
|
369
|
+
export function hubCanonicalEvents(job = {}) {
|
|
204
370
|
if (!job.id) return [];
|
|
205
371
|
const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
|
|
206
372
|
const atStart = job.startedAt ?? null;
|
|
@@ -312,6 +478,9 @@ export function selectProviderProbeModel({ providerId, record, config } = {}) {
|
|
|
312
478
|
export class WorkerRegistry { constructor(ctx) {
|
|
313
479
|
this.ctx = ctx;
|
|
314
480
|
this.jobs = new Map();
|
|
481
|
+
this.providerMutations = new Set();
|
|
482
|
+
this.providerMutationEpochs = new Map();
|
|
483
|
+
this.providerLeases = new Map();
|
|
315
484
|
this.nextId = 1;
|
|
316
485
|
this.shard = createShardWriter('hub');
|
|
317
486
|
this.healthStore = createProviderHealthStore();
|
|
@@ -357,9 +526,45 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
357
526
|
return v;
|
|
358
527
|
}
|
|
359
528
|
|
|
360
|
-
list() {
|
|
361
|
-
return [...this.jobs.values()].map((job) => this.view(job));
|
|
362
|
-
}
|
|
529
|
+
list() {
|
|
530
|
+
return [...this.jobs.values()].map((job) => this.view(job));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
beginProviderMutation(providerId) {
|
|
534
|
+
if (typeof providerId !== 'string' || !providerId.trim() || this.providerMutations.has(providerId)) return false;
|
|
535
|
+
this.providerMutationEpochs.set(providerId, (this.providerMutationEpochs.get(providerId) ?? 0) + 1);
|
|
536
|
+
this.providerMutations.add(providerId);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
endProviderMutation(providerId) {
|
|
541
|
+
this.providerMutations.delete(providerId);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
getProviderMutationEpoch(providerId) {
|
|
545
|
+
return this.providerMutationEpochs.get(providerId) ?? 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
assertProviderNotMutating(providerId, expectedEpoch = undefined) {
|
|
549
|
+
if (this.providerMutations.has(providerId)
|
|
550
|
+
|| (expectedEpoch !== undefined && this.getProviderMutationEpoch(providerId) !== expectedEpoch)) {
|
|
551
|
+
throw Object.assign(new Error('provider mutation is active'), { code: 'PROVIDER_DELETE_BUSY' });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
acquireProviderLease(providerId) {
|
|
556
|
+
this.providerLeases.set(providerId, (this.providerLeases.get(providerId) ?? 0) + 1);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
releaseProviderLease(providerId) {
|
|
560
|
+
const count = this.providerLeases.get(providerId) ?? 0;
|
|
561
|
+
if (count <= 1) this.providerLeases.delete(providerId);
|
|
562
|
+
else this.providerLeases.set(providerId, count - 1);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
hasProviderLease(providerId) {
|
|
566
|
+
return (this.providerLeases.get(providerId) ?? 0) > 0;
|
|
567
|
+
}
|
|
363
568
|
|
|
364
569
|
publish() {
|
|
365
570
|
this.shard.publish([...this.jobs.values()].map((j) => this.view(j)));
|
|
@@ -467,8 +672,10 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
467
672
|
},
|
|
468
673
|
});
|
|
469
674
|
}
|
|
470
|
-
if (!selection.ok) throw Object.assign(new Error(selection.message), { policyCode: selection.code });
|
|
471
|
-
}
|
|
675
|
+
if (!selection.ok) throw Object.assign(new Error(selection.message), { policyCode: selection.code });
|
|
676
|
+
}
|
|
677
|
+
const providerMutationEpoch = this.getProviderMutationEpoch(selection.provider);
|
|
678
|
+
this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
|
|
472
679
|
|
|
473
680
|
// The worker always gets the auditable Delivery Contract appended (unless
|
|
474
681
|
// it already carries one), so its final message follows ## Diff / ## Tests
|
|
@@ -527,9 +734,13 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
527
734
|
// Abort latch: cancel()/timeout set this the moment the job becomes
|
|
528
735
|
// terminal, even while agents.create() is still pending, so a handle
|
|
529
736
|
// acquired after that point is disposed instead of being driven.
|
|
530
|
-
abortRequested: false,
|
|
737
|
+
abortRequested: false,
|
|
531
738
|
abortPromise: null,
|
|
532
739
|
resolveAbort: null,
|
|
740
|
+
providerLease: false,
|
|
741
|
+
providerLeaseReleaseBlocked: false,
|
|
742
|
+
lateHandleCreated: false,
|
|
743
|
+
lateCreateCleanupPromise: null,
|
|
533
744
|
};
|
|
534
745
|
job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
|
|
535
746
|
const disposeJobHandle = async () => {
|
|
@@ -541,14 +752,30 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
541
752
|
job.handle_dispose_promise = Promise.reject(error);
|
|
542
753
|
}
|
|
543
754
|
}
|
|
544
|
-
|
|
755
|
+
try {
|
|
756
|
+
await job.handle_dispose_promise;
|
|
757
|
+
} catch (error) {
|
|
758
|
+
// A failed disposer is not proof that the provider is idle. Keep the
|
|
759
|
+
// lease fenced even when the job is already terminal; the operator
|
|
760
|
+
// must restart/reclaim the owned runtime before deletion can proceed.
|
|
761
|
+
job.providerLeaseReleaseBlocked = true;
|
|
762
|
+
throw error;
|
|
763
|
+
}
|
|
545
764
|
};
|
|
546
765
|
job.disposeHandle = disposeJobHandle;
|
|
547
766
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
548
767
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
549
768
|
// degrade to { kind:'no-git' } instead of failing the job.
|
|
550
|
-
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
551
|
-
|
|
769
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
770
|
+
try {
|
|
771
|
+
this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
|
|
772
|
+
this.acquireProviderLease(selection.provider);
|
|
773
|
+
job.providerLease = true;
|
|
774
|
+
} catch (error) {
|
|
775
|
+
if (isolatedWorkspace) await cleanupIsolatedWorkspace({ repoRoot: isolatedWorkspace.repoRoot, worktreePath: isolatedWorkspace.worktreePath }).catch(() => {});
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
778
|
+
this.jobs.set(id, job);
|
|
552
779
|
|
|
553
780
|
const onEvent = (session, event) => {
|
|
554
781
|
switch (event.type) {
|
|
@@ -598,14 +825,15 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
598
825
|
job.abortPromise.then(() => ({ aborted: true })),
|
|
599
826
|
]);
|
|
600
827
|
if (created.aborted) {
|
|
601
|
-
createPromise.then(async (handle) => {
|
|
602
|
-
if (handle
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
|
|
828
|
+
job.lateCreateCleanupPromise = createPromise.then(async (handle) => {
|
|
829
|
+
if (!handle) return;
|
|
830
|
+
job.lateHandleCreated = true;
|
|
831
|
+
job.handle = handle;
|
|
832
|
+
if (job.status !== 'running' || job.abortRequested) await job.disposeHandle();
|
|
833
|
+
}).catch((error) => {
|
|
834
|
+
job.cleanup_warning = `late agent creation failed: ${error?.message ?? String(error)}`;
|
|
835
|
+
throw error;
|
|
836
|
+
});
|
|
609
837
|
return;
|
|
610
838
|
}
|
|
611
839
|
const handle = created.handle;
|
|
@@ -674,9 +902,23 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
674
902
|
job.endedAt = new Date().toISOString();
|
|
675
903
|
job.currentTool = null;
|
|
676
904
|
let handleCleanupWarning = job.cleanup_warning ?? null;
|
|
677
|
-
try { await disposeJobHandle(); } catch (error) {
|
|
678
|
-
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
679
|
-
}
|
|
905
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
906
|
+
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
907
|
+
}
|
|
908
|
+
if (job.providerLease) {
|
|
909
|
+
const releaseLease = () => {
|
|
910
|
+
if (!job.providerLease) return;
|
|
911
|
+
this.releaseProviderLease(job.provider);
|
|
912
|
+
job.providerLease = false;
|
|
913
|
+
};
|
|
914
|
+
if (job.lateCreateCleanupPromise) {
|
|
915
|
+
job.lateCreateCleanupPromise.then(releaseLease).catch(() => {
|
|
916
|
+
if (!job.lateHandleCreated && !job.providerLeaseReleaseBlocked) releaseLease();
|
|
917
|
+
// A late-created handle that failed disposal remains fenced by
|
|
918
|
+
// the provider lease until an operator restarts/reclaims it.
|
|
919
|
+
});
|
|
920
|
+
} else if (!job.providerLeaseReleaseBlocked) releaseLease();
|
|
921
|
+
}
|
|
680
922
|
// Delivery completeness is separate from execution status: a job can
|
|
681
923
|
// be done yet fail to report Diff/Tests/Risks (or Review sections for
|
|
682
924
|
// an automatic review). Parse whatever final message the worker
|
|
@@ -1363,21 +1605,74 @@ export async function apply(ctx) {
|
|
|
1363
1605
|
try {
|
|
1364
1606
|
const providerId = decodeURIComponent(parts[0]);
|
|
1365
1607
|
const body = await readBody(req);
|
|
1608
|
+
if (req.method === 'POST' && parts[0] === '_recovery' && parts[1] === 'lock' && parts.length === 2) {
|
|
1609
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_CONFIRM_REQUIRED' });
|
|
1610
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1611
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1612
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1613
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1614
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1615
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1616
|
+
});
|
|
1617
|
+
const recovered = await hooks.recoverLock({ force: body?.force === true });
|
|
1618
|
+
return sendJson(res, recovered.ok ? 200 : 409, recovered);
|
|
1619
|
+
}
|
|
1620
|
+
if (req.method === 'POST' && parts[0] === '_recovery' && parts[1] === 'quarantine' && parts.length === 2) {
|
|
1621
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_QUARANTINE_CONFIRM_REQUIRED' });
|
|
1622
|
+
const actionId = typeof body?.action_id === 'string' && RECOVERY_ACTION_PATTERN.test(body.action_id)
|
|
1623
|
+
? body.action_id : null;
|
|
1624
|
+
if (!actionId) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_PLAN_INVALID' });
|
|
1625
|
+
const recovery = readProviderRecoveryTransactions().find((entry) => entry.action_id === actionId);
|
|
1626
|
+
if (!recovery) return sendJson(res, 404, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_NOT_FOUND' });
|
|
1627
|
+
if (recovery.recoverable === true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_RECOVERABLE' });
|
|
1628
|
+
if (typeof recovery.storage_id !== 'string') return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_QUARANTINE_FAILED' });
|
|
1629
|
+
try {
|
|
1630
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1631
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1632
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1633
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1634
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1635
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1636
|
+
afterLockAcquired: () => {
|
|
1637
|
+
const current = readProviderRecoveryTransactions().find((entry) => entry.action_id === actionId);
|
|
1638
|
+
if (!current || current.recoverable === true || current.storage_id !== recovery.storage_id) {
|
|
1639
|
+
throw Object.assign(new Error('provider recovery entry changed before quarantine'), { code: 'PROVIDER_DELETE_STATE_CHANGED' });
|
|
1640
|
+
}
|
|
1641
|
+
},
|
|
1642
|
+
});
|
|
1643
|
+
const quarantined = await hooks.quarantine(recovery.storage_id);
|
|
1644
|
+
return sendJson(res, 200, { ...quarantined, action_id: actionId, transaction_id: recovery.transaction_id ?? null });
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
const code = boundedMachineCodeFromError(error) ?? 'PROVIDER_DELETE_RECOVERY_QUARANTINE_FAILED';
|
|
1647
|
+
return sendJson(res, 409, { ok: false, code });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1366
1650
|
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1367
1651
|
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1368
1652
|
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
|
|
1369
|
-
|
|
1370
|
-
if (!
|
|
1653
|
+
if (providerId !== 'deepseek-official' && hasPendingProviderRecoveryTransactions(inventory)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
|
|
1654
|
+
if (providerId !== 'deepseek-official' && !hasCompleteProviderCatalogEvidence(inventory.catalog_evidence)) return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' });
|
|
1655
|
+
if (providerId !== 'deepseek-official' && !hasCompleteProviderDeclarationEvidence(inventory.declaration_evidence)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_SOURCE_UNRESOLVED' });
|
|
1656
|
+
if (providerId !== 'deepseek-official' && !hasAvailableProviderLifecycleEvidence(inventory.lifecycle_evidence)) return sendJson(res, 503, { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE' });
|
|
1657
|
+
if (providerId !== 'deepseek-official' && inventory.default_evidence?.ok !== true) return sendJson(res, 409, { ok: false, code: inventory.default_evidence?.code ?? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' });
|
|
1658
|
+
const revisions = readProviderSourceRevisions();
|
|
1659
|
+
if (!revisions.profile.ok && !revisions.settings.ok) return sendJson(res, 409, { ok: false, code: revisions.profile.code ?? revisions.settings.code });
|
|
1660
|
+
const expectedRevision = (revisions.profile.ok ? revisions.profile.revision : null) ?? revisions.settings.revision;
|
|
1371
1661
|
const planned = planProviderDelete({
|
|
1372
1662
|
providerId,
|
|
1373
1663
|
inventory,
|
|
1374
1664
|
activeJobs: hub.list(),
|
|
1375
1665
|
replacementDefault: body?.replacement_default,
|
|
1376
|
-
expectedRevision
|
|
1666
|
+
expectedRevision,
|
|
1667
|
+
expectedRevisions: {
|
|
1668
|
+
profile: revisions.profile.ok ? revisions.profile.revision : null,
|
|
1669
|
+
settings: revisions.settings.ok ? revisions.settings.revision : null,
|
|
1670
|
+
},
|
|
1377
1671
|
});
|
|
1378
1672
|
if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
|
|
1379
|
-
|
|
1380
|
-
|
|
1673
|
+
const plan = { ...planned.plan, runtime_id_before: getHubRuntimeIdentity().runtime_id };
|
|
1674
|
+
rememberProviderDeletePlan(plan);
|
|
1675
|
+
return sendJson(res, 200, { ok: true, profile_revision: expectedRevision, plan });
|
|
1381
1676
|
}
|
|
1382
1677
|
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
|
|
1383
1678
|
const record = inventory.records.find((entry) => entry.id === providerId);
|
|
@@ -1413,6 +1708,7 @@ export async function apply(ctx) {
|
|
|
1413
1708
|
const { transaction_id: transactionId } = body ?? {};
|
|
1414
1709
|
const hooks = createProviderDeleteFileHooks({
|
|
1415
1710
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1711
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1416
1712
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1417
1713
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1418
1714
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1424,10 +1720,21 @@ export async function apply(ctx) {
|
|
|
1424
1720
|
await hooks.acquireLock();
|
|
1425
1721
|
try {
|
|
1426
1722
|
const verification = await hooks.verify(plan);
|
|
1427
|
-
|
|
1723
|
+
const live = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1724
|
+
verification.catalog_evidence = live.catalog_evidence;
|
|
1725
|
+
verification.default_evidence = live.default_evidence;
|
|
1726
|
+
verification.catalogAbsent = hasCompleteProviderCatalogEvidence(live.catalog_evidence)
|
|
1727
|
+
&& !live.records.some((entry) => entry.id === providerId && entry.lifecycle?.catalogued === true);
|
|
1728
|
+
verification.harnessDefault = plan.was_harness_default !== true || (
|
|
1729
|
+
live.harness_default?.provider === plan.replacement_default
|
|
1730
|
+
&& live.harness_default?.model === plan.replacement_default_model
|
|
1731
|
+
);
|
|
1732
|
+
verification.runtimeRestarted = hasProviderRuntimeRestartEvidence(plan.delete_runtime_id_before_restart, getHubRuntimeIdentity().runtime_id);
|
|
1733
|
+
if (verification?.providerAbsent !== true || !hasCompleteProviderCatalogEvidence(verification?.catalog_evidence) || verification?.default_evidence?.ok !== true || verification?.catalogAbsent !== true || verification?.harnessDefault !== true || verification?.runtimeRestarted !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
|
|
1428
1734
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
|
|
1429
1735
|
}
|
|
1430
1736
|
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
|
|
1737
|
+
hub.endProviderMutation(providerId);
|
|
1431
1738
|
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
|
|
1432
1739
|
} finally {
|
|
1433
1740
|
await hooks.release();
|
|
@@ -1438,6 +1745,7 @@ export async function apply(ctx) {
|
|
|
1438
1745
|
const { transaction_id: transactionId } = body ?? {};
|
|
1439
1746
|
const hooks = createProviderDeleteFileHooks({
|
|
1440
1747
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1748
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1441
1749
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1442
1750
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1443
1751
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1449,8 +1757,10 @@ export async function apply(ctx) {
|
|
|
1449
1757
|
const plan = hooks.backupPlan();
|
|
1450
1758
|
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1451
1759
|
await hooks.rollback(plan);
|
|
1760
|
+
await hooks.setRuntimeBaseline(getHubRuntimeIdentity().runtime_id, 'rollback');
|
|
1761
|
+
const rollbackPlan = hooks.backupPlan() ?? plan;
|
|
1452
1762
|
try {
|
|
1453
|
-
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' },
|
|
1763
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, rollbackPlan);
|
|
1454
1764
|
} catch (error) {
|
|
1455
1765
|
return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
|
|
1456
1766
|
}
|
|
@@ -1464,6 +1774,7 @@ export async function apply(ctx) {
|
|
|
1464
1774
|
const { transaction_id: transactionId } = body ?? {};
|
|
1465
1775
|
const hooks = createProviderDeleteFileHooks({
|
|
1466
1776
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1777
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1467
1778
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1468
1779
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1469
1780
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1475,8 +1786,19 @@ export async function apply(ctx) {
|
|
|
1475
1786
|
await hooks.acquireLock();
|
|
1476
1787
|
try {
|
|
1477
1788
|
const verified = await hooks.verifyRollback(plan);
|
|
1478
|
-
|
|
1789
|
+
const live = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1790
|
+
verified.catalog_evidence = live.catalog_evidence;
|
|
1791
|
+
verified.default_evidence = live.default_evidence;
|
|
1792
|
+
verified.catalogPresent = hasCompleteProviderCatalogEvidence(live.catalog_evidence)
|
|
1793
|
+
&& live.records.some((entry) => entry.id === providerId && entry.lifecycle?.catalogued === true);
|
|
1794
|
+
verified.harnessDefaultRestored = plan.was_harness_default !== true || (
|
|
1795
|
+
live.harness_default?.provider === plan.harness_default_before?.provider
|
|
1796
|
+
&& live.harness_default?.model === plan.harness_default_before?.model
|
|
1797
|
+
);
|
|
1798
|
+
verified.runtimeRestarted = hasProviderRuntimeRestartEvidence(plan.rollback_runtime_id_before_restart, getHubRuntimeIdentity().runtime_id);
|
|
1799
|
+
if (verified?.ok !== true || !hasCompleteProviderCatalogEvidence(verified?.catalog_evidence) || verified?.default_evidence?.ok !== true || verified?.catalogPresent !== true || verified?.harnessDefaultRestored !== true || verified?.runtimeRestarted !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED', verification: verified });
|
|
1479
1800
|
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
|
|
1801
|
+
hub.endProviderMutation(providerId);
|
|
1480
1802
|
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
|
|
1481
1803
|
} finally {
|
|
1482
1804
|
await hooks.release();
|
|
@@ -1492,33 +1814,67 @@ export async function apply(ctx) {
|
|
|
1492
1814
|
providerDeletePlans.delete(body?.plan_id);
|
|
1493
1815
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
|
|
1494
1816
|
}
|
|
1495
|
-
const
|
|
1496
|
-
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1497
|
-
if (
|
|
1817
|
+
const revisions = readProviderSourceRevisions();
|
|
1818
|
+
if (!revisions.profile.ok && !revisions.settings.ok) return sendJson(res, 409, { ok: false, code: revisions.profile.code ?? revisions.settings.code });
|
|
1819
|
+
if (providerId !== 'deepseek-official' && !hasCompleteProviderCatalogEvidence(inventory.catalog_evidence)) return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' });
|
|
1820
|
+
if (providerId !== 'deepseek-official' && !hasCompleteProviderDeclarationEvidence(inventory.declaration_evidence)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_SOURCE_UNRESOLVED' });
|
|
1821
|
+
if (providerId !== 'deepseek-official' && !hasAvailableProviderLifecycleEvidence(inventory.lifecycle_evidence)) return sendJson(res, 503, { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE' });
|
|
1822
|
+
if (providerId !== 'deepseek-official' && inventory.default_evidence?.ok !== true) return sendJson(res, 409, { ok: false, code: inventory.default_evidence?.code ?? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' });
|
|
1823
|
+
const storedExpected = stored.plan.expected_revisions;
|
|
1824
|
+
const revisionsMatch = storedExpected
|
|
1825
|
+
? (storedExpected.profile === (revisions.profile.ok ? revisions.profile.revision : null)
|
|
1826
|
+
&& storedExpected.settings === (revisions.settings.ok ? revisions.settings.revision : null))
|
|
1827
|
+
: (body?.expected_revision === stored.plan.expected_revision && (revisions.profile.revision ?? revisions.settings.revision) === stored.plan.expected_revision);
|
|
1828
|
+
if (!revisionsMatch || body?.expected_revision !== stored.plan.expected_revision) {
|
|
1498
1829
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
|
|
1499
1830
|
}
|
|
1831
|
+
if (providerId !== 'deepseek-official' && hasPendingProviderRecoveryTransactions(inventory)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
|
|
1500
1832
|
const refreshed = planProviderDelete({
|
|
1501
1833
|
providerId,
|
|
1502
1834
|
inventory,
|
|
1503
1835
|
activeJobs: hub.list(),
|
|
1504
1836
|
replacementDefault: stored.plan.replacement_default,
|
|
1505
|
-
expectedRevision:
|
|
1837
|
+
expectedRevision: stored.plan.expected_revision,
|
|
1838
|
+
expectedRevisions: stored.plan.expected_revisions,
|
|
1506
1839
|
});
|
|
1507
1840
|
if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
|
|
1508
1841
|
if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
|
|
1509
1842
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
|
|
1510
1843
|
}
|
|
1511
|
-
const executionPlan = {
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1844
|
+
const executionPlan = {
|
|
1845
|
+
...refreshed.plan,
|
|
1846
|
+
plan_id: stored.plan.plan_id,
|
|
1847
|
+
expected_revision: stored.plan.expected_revision,
|
|
1848
|
+
expected_revisions: stored.plan.expected_revisions,
|
|
1849
|
+
runtime_id_before: stored.plan.runtime_id_before,
|
|
1850
|
+
};
|
|
1851
|
+
if (!hub.beginProviderMutation(providerId)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_BUSY' });
|
|
1852
|
+
let retainProviderMutation = false;
|
|
1853
|
+
try {
|
|
1854
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1855
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1856
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1857
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1858
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1859
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1860
|
+
runtimeIdProvider: () => getHubRuntimeIdentity().runtime_id,
|
|
1861
|
+
afterLockAcquired: () => {
|
|
1862
|
+
if (readProviderRecoveryTransactions().length > 0) {
|
|
1863
|
+
throw Object.assign(new Error('provider recovery transaction is pending'), { code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
|
|
1864
|
+
}
|
|
1865
|
+
if (hub.hasProviderLease(providerId)) {
|
|
1866
|
+
throw Object.assign(new Error('provider is in use'), { code: 'PROVIDER_IN_USE' });
|
|
1867
|
+
}
|
|
1868
|
+
},
|
|
1869
|
+
});
|
|
1870
|
+
providerDeletePlans.delete(body.plan_id);
|
|
1871
|
+
const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
|
|
1872
|
+
const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
|
|
1873
|
+
retainProviderMutation = result.state === 'RESTART_PENDING';
|
|
1874
|
+
return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
|
|
1875
|
+
} finally {
|
|
1876
|
+
if (!retainProviderMutation) hub.endProviderMutation(providerId);
|
|
1877
|
+
}
|
|
1522
1878
|
}
|
|
1523
1879
|
return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
|
|
1524
1880
|
} catch (err) {
|