@ran-sh/dsh-crew 1.0.0 → 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 +420 -66
- 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
|
|
@@ -1129,13 +1371,11 @@ export async function apply(ctx) {
|
|
|
1129
1371
|
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
1130
1372
|
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
1131
1373
|
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
1132
|
-
const
|
|
1374
|
+
const executionRows = buildHubExecutionRows(liveJobs);
|
|
1133
1375
|
const readinessMatrix = { rows: [
|
|
1134
1376
|
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
1135
1377
|
catalogStatus,
|
|
1136
|
-
|
|
1137
|
-
reviewerExecution,
|
|
1138
|
-
{ id: 'provider_lifecycle_consistent', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' },
|
|
1378
|
+
...executionRows,
|
|
1139
1379
|
] };
|
|
1140
1380
|
const readinessSnapshot = buildRuntimeReadinessSnapshot({
|
|
1141
1381
|
runtime: getHubRuntimeIdentity(),
|
|
@@ -1365,21 +1605,74 @@ export async function apply(ctx) {
|
|
|
1365
1605
|
try {
|
|
1366
1606
|
const providerId = decodeURIComponent(parts[0]);
|
|
1367
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
|
+
}
|
|
1368
1650
|
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1369
1651
|
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1370
1652
|
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
|
|
1371
|
-
|
|
1372
|
-
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;
|
|
1373
1661
|
const planned = planProviderDelete({
|
|
1374
1662
|
providerId,
|
|
1375
1663
|
inventory,
|
|
1376
1664
|
activeJobs: hub.list(),
|
|
1377
1665
|
replacementDefault: body?.replacement_default,
|
|
1378
|
-
expectedRevision
|
|
1666
|
+
expectedRevision,
|
|
1667
|
+
expectedRevisions: {
|
|
1668
|
+
profile: revisions.profile.ok ? revisions.profile.revision : null,
|
|
1669
|
+
settings: revisions.settings.ok ? revisions.settings.revision : null,
|
|
1670
|
+
},
|
|
1379
1671
|
});
|
|
1380
1672
|
if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
|
|
1381
|
-
|
|
1382
|
-
|
|
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 });
|
|
1383
1676
|
}
|
|
1384
1677
|
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
|
|
1385
1678
|
const record = inventory.records.find((entry) => entry.id === providerId);
|
|
@@ -1415,6 +1708,7 @@ export async function apply(ctx) {
|
|
|
1415
1708
|
const { transaction_id: transactionId } = body ?? {};
|
|
1416
1709
|
const hooks = createProviderDeleteFileHooks({
|
|
1417
1710
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1711
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1418
1712
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1419
1713
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1420
1714
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1426,10 +1720,21 @@ export async function apply(ctx) {
|
|
|
1426
1720
|
await hooks.acquireLock();
|
|
1427
1721
|
try {
|
|
1428
1722
|
const verification = await hooks.verify(plan);
|
|
1429
|
-
|
|
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) {
|
|
1430
1734
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
|
|
1431
1735
|
}
|
|
1432
1736
|
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
|
|
1737
|
+
hub.endProviderMutation(providerId);
|
|
1433
1738
|
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
|
|
1434
1739
|
} finally {
|
|
1435
1740
|
await hooks.release();
|
|
@@ -1440,6 +1745,7 @@ export async function apply(ctx) {
|
|
|
1440
1745
|
const { transaction_id: transactionId } = body ?? {};
|
|
1441
1746
|
const hooks = createProviderDeleteFileHooks({
|
|
1442
1747
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1748
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1443
1749
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1444
1750
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1445
1751
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1451,8 +1757,10 @@ export async function apply(ctx) {
|
|
|
1451
1757
|
const plan = hooks.backupPlan();
|
|
1452
1758
|
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1453
1759
|
await hooks.rollback(plan);
|
|
1760
|
+
await hooks.setRuntimeBaseline(getHubRuntimeIdentity().runtime_id, 'rollback');
|
|
1761
|
+
const rollbackPlan = hooks.backupPlan() ?? plan;
|
|
1454
1762
|
try {
|
|
1455
|
-
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);
|
|
1456
1764
|
} catch (error) {
|
|
1457
1765
|
return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
|
|
1458
1766
|
}
|
|
@@ -1466,6 +1774,7 @@ export async function apply(ctx) {
|
|
|
1466
1774
|
const { transaction_id: transactionId } = body ?? {};
|
|
1467
1775
|
const hooks = createProviderDeleteFileHooks({
|
|
1468
1776
|
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1777
|
+
settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
|
|
1469
1778
|
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1470
1779
|
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1471
1780
|
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
@@ -1477,8 +1786,19 @@ export async function apply(ctx) {
|
|
|
1477
1786
|
await hooks.acquireLock();
|
|
1478
1787
|
try {
|
|
1479
1788
|
const verified = await hooks.verifyRollback(plan);
|
|
1480
|
-
|
|
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 });
|
|
1481
1800
|
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
|
|
1801
|
+
hub.endProviderMutation(providerId);
|
|
1482
1802
|
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
|
|
1483
1803
|
} finally {
|
|
1484
1804
|
await hooks.release();
|
|
@@ -1494,33 +1814,67 @@ export async function apply(ctx) {
|
|
|
1494
1814
|
providerDeletePlans.delete(body?.plan_id);
|
|
1495
1815
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
|
|
1496
1816
|
}
|
|
1497
|
-
const
|
|
1498
|
-
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1499
|
-
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) {
|
|
1500
1829
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
|
|
1501
1830
|
}
|
|
1831
|
+
if (providerId !== 'deepseek-official' && hasPendingProviderRecoveryTransactions(inventory)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
|
|
1502
1832
|
const refreshed = planProviderDelete({
|
|
1503
1833
|
providerId,
|
|
1504
1834
|
inventory,
|
|
1505
1835
|
activeJobs: hub.list(),
|
|
1506
1836
|
replacementDefault: stored.plan.replacement_default,
|
|
1507
|
-
expectedRevision:
|
|
1837
|
+
expectedRevision: stored.plan.expected_revision,
|
|
1838
|
+
expectedRevisions: stored.plan.expected_revisions,
|
|
1508
1839
|
});
|
|
1509
1840
|
if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
|
|
1510
1841
|
if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
|
|
1511
1842
|
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
|
|
1512
1843
|
}
|
|
1513
|
-
const executionPlan = {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
+
}
|
|
1524
1878
|
}
|
|
1525
1879
|
return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
|
|
1526
1880
|
} catch (err) {
|