@ran-sh/dsh-crew 0.5.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +20 -7
  2. package/README.zh.md +18 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +565 -28
  6. package/official-web-bridge/lib/client.js +565 -28
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/host-readiness.mjs +16 -3
  10. package/src/client/index.tsx +331 -54
  11. package/src/config-readiness.mjs +66 -5
  12. package/src/credential-lifecycle.mjs +73 -0
  13. package/src/credential-purge-adapters.mjs +127 -0
  14. package/src/credential-purge-state.mjs +59 -0
  15. package/src/credential-reference-inventory.mjs +78 -0
  16. package/src/dsh-cli-runtime.mjs +36 -9
  17. package/src/extension-contract.mjs +16 -10
  18. package/src/hub/index.mjs +1004 -438
  19. package/src/hub-client.mjs +25 -5
  20. package/src/hub-compatibility.mjs +23 -7
  21. package/src/install/install.mjs +4 -4
  22. package/src/install/npx-lifecycle.mjs +495 -134
  23. package/src/job-contracts.mjs +27 -6
  24. package/src/jobs.mjs +2 -4
  25. package/src/local-request-guard.mjs +60 -0
  26. package/src/mcp-runtime.mjs +7 -5
  27. package/src/model-routing.mjs +141 -49
  28. package/src/multimodal.mjs +0 -0
  29. package/src/official-web-bridge.mjs +248 -95
  30. package/src/policy.mjs +62 -31
  31. package/src/provider-config-scrub.mjs +71 -0
  32. package/src/provider-delete-adapters.mjs +451 -0
  33. package/src/provider-desired-state.mjs +37 -0
  34. package/src/provider-health.mjs +124 -0
  35. package/src/provider-inventory.mjs +132 -0
  36. package/src/provider-lifecycle-state.mjs +95 -0
  37. package/src/provider-lifecycle.mjs +214 -0
  38. package/src/provider-profile-store.mjs +171 -0
  39. package/src/readiness-matrix.mjs +11 -5
  40. package/src/removable-waiter.mjs +29 -0
  41. package/src/runtime-identity.mjs +66 -21
  42. package/src/runtime-readiness-snapshot.mjs +87 -0
  43. package/src/server.mjs +64 -21
  44. package/src/status-shard.mjs +13 -2
  45. package/src/workflow-runtime.mjs +44 -10
  46. package/windows/start-dsh-crew.cmd +3 -3
  47. package/windows/start-dsh-crew.ps1 +49 -5
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ // Persistent, secret-free lifecycle intent for Harness provider management.
2
+ // Tombstones prevent an explicitly removed managed route from being re-seeded;
3
+ // transactions retain only bounded audit metadata needed for recovery.
4
+
5
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
+ const REVISION_PATTERN = /^[a-f0-9]{64}$/;
7
+ const STATES = new Set([
8
+ 'PLANNED', 'BLOCKED', 'APPLIED', 'RESTART_PENDING', 'VERIFYING',
9
+ 'VERIFIED', 'ROLLBACK_PENDING', 'ROLLED_BACK', 'FAILED',
10
+ ]);
11
+
12
+ function validId(value) {
13
+ return typeof value === 'string' && ID_PATTERN.test(value);
14
+ }
15
+
16
+ function validRevision(value) {
17
+ return typeof value === 'string' && REVISION_PATTERN.test(value);
18
+ }
19
+
20
+ function normalizedCredentialRefs(value) {
21
+ if (!Array.isArray(value)) return undefined;
22
+ const refs = value.map((ref) => {
23
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return null;
24
+ const kind = typeof ref.kind === 'string' && ref.kind.trim() ? ref.kind.trim() : null;
25
+ const name = typeof ref.name_or_handle === 'string' && ref.name_or_handle.trim() ? ref.name_or_handle.trim() : null;
26
+ const ownership = typeof ref.ownership === 'string' && ref.ownership.trim() ? ref.ownership.trim() : null;
27
+ return kind && name && ownership ? { kind, name_or_handle: name, ownership } : null;
28
+ }).filter(Boolean).slice(0, 32);
29
+ return refs;
30
+ }
31
+
32
+ function clone(value) {
33
+ return JSON.parse(JSON.stringify(value));
34
+ }
35
+
36
+ function normalizedTransaction(value) {
37
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
38
+ if (!validId(value.provider_id) || typeof value.state !== 'string' || !STATES.has(value.state)) return null;
39
+ const refs = normalizedCredentialRefs(value.credential_refs);
40
+ return {
41
+ provider_id: value.provider_id,
42
+ state: value.state,
43
+ ...(validRevision(value.expected_revision) ? { expected_revision: value.expected_revision } : {}),
44
+ ...(refs === undefined ? {} : { credential_refs: refs }),
45
+ };
46
+ }
47
+
48
+ /** Normalize persisted lifecycle metadata; unknown fields and secret values are discarded. */
49
+ export function normalizeProviderLifecycleState(input = {}) {
50
+ const out = { schema_version: 1, tombstones: {}, transactions: {}, last_verified_revision: {} };
51
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return out;
52
+ if (input.tombstones && typeof input.tombstones === 'object' && !Array.isArray(input.tombstones)) {
53
+ for (const [id, value] of Object.entries(input.tombstones)) if (validId(id) && value === 'absent') out.tombstones[id] = 'absent';
54
+ }
55
+ if (input.transactions && typeof input.transactions === 'object' && !Array.isArray(input.transactions)) {
56
+ for (const [transactionId, value] of Object.entries(input.transactions)) {
57
+ if (!validId(transactionId)) continue;
58
+ const transaction = normalizedTransaction(value);
59
+ if (transaction) out.transactions[transactionId] = transaction;
60
+ }
61
+ }
62
+ if (input.last_verified_revision && typeof input.last_verified_revision === 'object' && !Array.isArray(input.last_verified_revision)) {
63
+ for (const [id, revision] of Object.entries(input.last_verified_revision)) if (validId(id) && validRevision(revision)) out.last_verified_revision[id] = revision;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function requireProviderId(providerId) {
69
+ if (!validId(providerId)) throw new TypeError('provider id must be a bounded identifier');
70
+ return providerId;
71
+ }
72
+
73
+ export function markProviderTombstone(state, providerId) {
74
+ const id = requireProviderId(providerId);
75
+ const out = normalizeProviderLifecycleState(state);
76
+ out.tombstones[id] = 'absent';
77
+ return out;
78
+ }
79
+
80
+ export function clearProviderTombstone(state, providerId) {
81
+ const id = requireProviderId(providerId);
82
+ const out = normalizeProviderLifecycleState(state);
83
+ delete out.tombstones[id];
84
+ return out;
85
+ }
86
+
87
+ /** Record non-secret transaction metadata and credential reference names only. */
88
+ export function recordProviderTransaction(state, transaction = {}) {
89
+ const transactionId = requireProviderId(transaction.transaction_id ?? transaction.plan_id);
90
+ const normalized = normalizedTransaction(transaction);
91
+ if (!normalized) throw new TypeError('provider transaction metadata is invalid');
92
+ const out = normalizeProviderLifecycleState(state);
93
+ out.transactions[transactionId] = clone(normalized);
94
+ return out;
95
+ }
@@ -0,0 +1,214 @@
1
+ // Provider deletion planning and transaction state machine.
2
+ //
3
+ // This module is intentionally side-effect free until executeProviderDelete is
4
+ // given explicit adapters. The live profile, credentials and restart hooks are
5
+ // owned by the caller; plans never carry secret values.
6
+
7
+ import { randomUUID } from 'node:crypto';
8
+
9
+ export const PROVIDER_DELETE_STATES = Object.freeze([
10
+ 'PLANNED', 'BLOCKED', 'APPLIED', 'RESTART_PENDING', 'VERIFYING',
11
+ 'VERIFIED', 'ROLLBACK_PENDING', 'ROLLED_BACK', 'FAILED',
12
+ ]);
13
+
14
+ const TRANSITIONS = Object.freeze({
15
+ PLANNED: Object.freeze(['BLOCKED', 'APPLIED', 'FAILED']),
16
+ BLOCKED: Object.freeze(['PLANNED', 'FAILED']),
17
+ APPLIED: Object.freeze(['RESTART_PENDING', 'FAILED']),
18
+ RESTART_PENDING: Object.freeze(['VERIFYING', 'FAILED']),
19
+ VERIFYING: Object.freeze(['VERIFIED', 'FAILED']),
20
+ VERIFIED: Object.freeze(['ROLLBACK_PENDING']),
21
+ ROLLBACK_PENDING: Object.freeze(['ROLLED_BACK', 'FAILED']),
22
+ ROLLED_BACK: Object.freeze([]),
23
+ FAILED: Object.freeze([]),
24
+ });
25
+
26
+ const DELETEABLE_OWNERSHIPS = new Set(['crew-managed-profile', 'user-managed-profile', 'dynamic-user']);
27
+ const CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
28
+
29
+ function text(value) {
30
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
31
+ }
32
+
33
+ function boundedCode(value, fallback = 'PROVIDER_DELETE_FAILED') {
34
+ const candidate = text(value);
35
+ return candidate && CODE_PATTERN.test(candidate) ? candidate : fallback;
36
+ }
37
+
38
+ function fail(code) {
39
+ return { ok: false, code };
40
+ }
41
+
42
+ function recordById(inventory, providerId) {
43
+ const records = Array.isArray(inventory?.records) ? inventory.records : [];
44
+ return records.find((record) => record?.id === providerId) ?? null;
45
+ }
46
+
47
+ function activeJobCount(record, activeJobs) {
48
+ if (Array.isArray(activeJobs)) return activeJobs.filter((job) => job?.provider === record.id).length;
49
+ return Number.isInteger(record?.references?.active_jobs) ? Math.max(0, record.references.active_jobs) : 0;
50
+ }
51
+
52
+ function sanitizedCredentialRefs(record) {
53
+ return (Array.isArray(record?.credential_refs) ? record.credential_refs : []).map((ref) => ({
54
+ kind: text(ref?.kind) ?? 'unknown',
55
+ name_or_handle: text(ref?.name_or_handle) ?? 'unknown',
56
+ ownership: text(ref?.ownership) ?? 'unknown',
57
+ }));
58
+ }
59
+
60
+ function pushState(events, state) {
61
+ events.push({ state, at: new Date().toISOString() });
62
+ }
63
+
64
+ /** Return whether a provider deletion state transition is legal. */
65
+ export function canTransitionProviderDelete(from, to) {
66
+ return PROVIDER_DELETE_STATES.includes(from) && TRANSITIONS[from]?.includes(to) === true;
67
+ }
68
+
69
+ /**
70
+ * Validate a provider deletion request and return a secret-free impact plan.
71
+ * The caller must provide a replacement when deleting the active Harness
72
+ * Default; running jobs and unknown ownership always fail closed.
73
+ */
74
+ export function planProviderDelete({ providerId, inventory, activeJobs = [], replacementDefault = null, expectedRevision } = {}) {
75
+ const id = text(providerId);
76
+ if (!id) return fail('PROVIDER_NOT_FOUND');
77
+ const record = recordById(inventory, id);
78
+ if (!record) return fail('PROVIDER_NOT_FOUND');
79
+ if (!DELETEABLE_OWNERSHIPS.has(record.ownership)) return fail('PROVIDER_OWNERSHIP_AMBIGUOUS');
80
+ if (record.desired_state === 'absent') return fail('PROVIDER_ALREADY_ABSENT');
81
+ if (activeJobCount(record, activeJobs) > 0) return fail('PROVIDER_IN_USE');
82
+
83
+ const replacement = text(replacementDefault);
84
+ const replacementRecord = replacement ? recordById(inventory, replacement) : null;
85
+ if (record.references?.harness_default) {
86
+ if (!replacement || replacement === id || !replacementRecord || replacementRecord.desired_state === 'absent') {
87
+ return fail('PROVIDER_DEFAULT_REPLACEMENT_REQUIRED');
88
+ }
89
+ if (typeof replacementRecord.models?.[0] !== 'string' || replacementRecord.models[0].trim() === '') {
90
+ return fail('PROVIDER_DEFAULT_REPLACEMENT_MODEL_REQUIRED');
91
+ }
92
+ }
93
+
94
+ return {
95
+ ok: true,
96
+ plan: {
97
+ plan_id: randomUUID(),
98
+ provider_id: id,
99
+ provider: { id, display_name: text(record.display_name) ?? id },
100
+ expected_revision: text(expectedRevision) ?? text(record.declaration?.revision),
101
+ replacement_default: replacement,
102
+ was_harness_default: record.references?.harness_default === true,
103
+ ...(replacement
104
+ ? { replacement_default_model: text(replacementRecord?.models?.[0]) }
105
+ : {}),
106
+ will_remove: [
107
+ 'profile declaration', 'runtime desired registration',
108
+ 'worker priority references', 'reviewer priority references',
109
+ 'Harness Default reference', 'cache/health state',
110
+ ],
111
+ credential_refs: sanitizedCredentialRefs(record),
112
+ restart_required: true,
113
+ rollback: { config_snapshot_available: true, credentials_included: false },
114
+ },
115
+ };
116
+ }
117
+
118
+ function hookError(name) {
119
+ return Object.assign(new Error(`provider delete hook missing: ${name}`), { code: 'PROVIDER_LIFECYCLE_HOOK_MISSING' });
120
+ }
121
+
122
+ /**
123
+ * Execute a deletion plan through injected adapters. All mutating adapters are
124
+ * explicit so the Hub/API layer can provide atomic profile/config stores and a
125
+ * supervisor that only restarts the owned 3210 child.
126
+ */
127
+ export async function executeProviderDelete(plan, hooks = {}, { deferRestart = false } = {}) {
128
+ let state = 'PLANNED';
129
+ const events = [];
130
+ pushState(events, state);
131
+ let errorCode = null;
132
+ let verification = null;
133
+ let mutationsStarted = false;
134
+ let rollbackAttempted = false;
135
+ let rollbackErrorCode = null;
136
+ let rollbackRuntimeRestarted = null;
137
+ let rollbackRuntimeVerified = null;
138
+ const transition = (next) => {
139
+ if (!canTransitionProviderDelete(state, next)) throw Object.assign(new Error(`illegal provider delete transition ${state} -> ${next}`), { code: 'PROVIDER_DELETE_STATE_INVALID' });
140
+ state = next;
141
+ pushState(events, state);
142
+ };
143
+ try {
144
+ if (!plan?.plan_id || !plan?.provider_id) throw Object.assign(new Error('provider delete plan is invalid'), { code: 'PROVIDER_DELETE_PLAN_INVALID' });
145
+ for (const name of ['backup', 'markTombstone', 'scrubReferences', 'removeDeclarations', 'restart', 'rollback']) {
146
+ if (typeof hooks[name] !== 'function') throw hookError(name);
147
+ }
148
+ await hooks.backup(plan);
149
+ // From this point onward at least one destructive adapter may have
150
+ // committed; any later failure must attempt compensation before returning.
151
+ mutationsStarted = true;
152
+ await hooks.markTombstone(plan.provider_id, 'absent', plan);
153
+ await hooks.scrubReferences(plan);
154
+ await hooks.removeDeclarations(plan);
155
+ transition('APPLIED');
156
+ transition('RESTART_PENDING');
157
+ if (!deferRestart) {
158
+ const restarted = await hooks.restart(plan);
159
+ if (restarted?.ok === false) throw Object.assign(new Error('provider delete restart failed'), { code: restarted.code });
160
+ if (typeof hooks.verify !== 'function') throw hookError('verify');
161
+ transition('VERIFYING');
162
+ verification = await hooks.verify(plan);
163
+ if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
164
+ throw Object.assign(new Error('provider delete verification incomplete'), { code: 'PROVIDER_DELETE_VERIFY_FAILED' });
165
+ }
166
+ transition('VERIFIED');
167
+ }
168
+ } catch (error) {
169
+ errorCode = boundedCode(error?.code);
170
+ if (mutationsStarted && typeof hooks.rollback === 'function') {
171
+ rollbackAttempted = true;
172
+ try {
173
+ await hooks.rollback(plan);
174
+ if (typeof hooks.restartRollback === 'function') {
175
+ const restarted = await hooks.restartRollback(plan);
176
+ rollbackRuntimeRestarted = restarted?.ok !== false;
177
+ if (rollbackRuntimeRestarted && typeof hooks.verifyRollback === 'function') {
178
+ const verified = await hooks.verifyRollback(plan);
179
+ rollbackRuntimeVerified = verified?.ok !== false;
180
+ }
181
+ }
182
+ } catch (rollbackError) {
183
+ rollbackErrorCode = boundedCode(rollbackError?.code, 'PROVIDER_DELETE_ROLLBACK_FAILED');
184
+ rollbackRuntimeRestarted = false;
185
+ }
186
+ }
187
+ if (state !== 'FAILED' && canTransitionProviderDelete(state, 'FAILED')) transition('FAILED');
188
+ }
189
+ const result = {
190
+ transaction_id: plan?.plan_id ?? null,
191
+ provider_id: plan?.provider_id ?? null,
192
+ state,
193
+ error_code: errorCode,
194
+ rollback_attempted: rollbackAttempted,
195
+ rollback_error_code: rollbackErrorCode,
196
+ rollback_runtime_restarted: rollbackRuntimeRestarted,
197
+ rollback_runtime_verified: rollbackRuntimeVerified,
198
+ verification,
199
+ events,
200
+ };
201
+ if (typeof hooks.recordTransaction === 'function') {
202
+ try {
203
+ await hooks.recordTransaction(result, plan);
204
+ result.audit_recorded = true;
205
+ } catch (error) {
206
+ result.audit_recorded = false;
207
+ result.audit_error_code = boundedCode(error?.code, 'PROVIDER_LIFECYCLE_RECORD_FAILED');
208
+ }
209
+ }
210
+ if (typeof hooks.release === 'function') {
211
+ try { await hooks.release(plan); } catch {}
212
+ }
213
+ return result;
214
+ }
@@ -0,0 +1,171 @@
1
+ // Safe, structure-aware mutations for the Crew profile's provider patch.
2
+ //
3
+ // This module deliberately does not parse or return credential values. It only
4
+ // recognizes the known llm-pi-ai -> config -> providers mapping and performs
5
+ // bounded, revision-checked text edits while preserving unrelated patch items.
6
+
7
+ import { createHash } from 'node:crypto';
8
+
9
+ const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+
11
+ function sha256(value) {
12
+ return createHash('sha256').update(value, 'utf8').digest('hex');
13
+ }
14
+
15
+ function indentOf(line) {
16
+ return (line.match(/^\s*/) ?? [''])[0].length;
17
+ }
18
+
19
+ function nonBlank(line) {
20
+ return line.trim() !== '' && !line.trimStart().startsWith('#');
21
+ }
22
+
23
+ function parseProviderMap(source) {
24
+ if (typeof source !== 'string') return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
25
+ const lines = source.split(/\r?\n/);
26
+ const llmStart = lines.findIndex((line) => /^-\s+id:\s*llm-pi-ai\s*$/.test(line));
27
+ if (llmStart < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
28
+
29
+ let blockEnd = lines.length;
30
+ for (let index = llmStart + 1; index < lines.length; index += 1) {
31
+ // Only a true top-level sequence item can end the managed llm-pi-ai item;
32
+ // nested list entries are part of a provider value and must be validated.
33
+ if (indentOf(lines[index]) === 0 && /^-\s+/.test(lines[index])) {
34
+ blockEnd = index;
35
+ break;
36
+ }
37
+ }
38
+
39
+ const providersLine = lines.findIndex((line, index) => index > llmStart && index < blockEnd && /^\s+providers:\s*$/.test(line));
40
+ if (providersLine < 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
41
+ const providersIndent = indentOf(lines[providersLine]);
42
+ const providerIndent = providersIndent + 2;
43
+ const entries = [];
44
+
45
+ for (let index = providersLine + 1; index < blockEnd; index += 1) {
46
+ const line = lines[index];
47
+ if (!nonBlank(line)) continue;
48
+ const indent = indentOf(line);
49
+ if (indent <= providersIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
50
+ if (indent !== providerIndent) {
51
+ if (/^\s*-\s+/.test(line)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
52
+ continue;
53
+ }
54
+ const match = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
55
+ if (!match) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
56
+ const id = match[1];
57
+ let end = index + 1;
58
+ while (end < blockEnd) {
59
+ const next = lines[end];
60
+ if (nonBlank(next)) {
61
+ const nextIndent = indentOf(next);
62
+ if (nextIndent < providerIndent) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
63
+ if (nextIndent === providerIndent) break;
64
+ // Provider values may contain nested YAML sequences (for example the
65
+ // `models:` list emitted by the live Harness profile). A sequence at
66
+ // the same indentation as a provider field is malformed, however;
67
+ // valid child items must be indented below that field. Keep the
68
+ // parser structure-aware without attempting to deserialize values.
69
+ if (/^\s*-\s+/.test(next) && nextIndent < providerIndent + 4) {
70
+ return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
71
+ }
72
+ }
73
+ end += 1;
74
+ }
75
+ entries.push({ id, start: index, end });
76
+ index = end - 1;
77
+ }
78
+ if (entries.length === 0) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
79
+ return { ok: true, lines, llmStart, blockEnd, providersLine, providerIndent, entries };
80
+ }
81
+
82
+ /** Return only bounded provider ids and a content revision; never values. */
83
+ export function inspectProviderProfile(source) {
84
+ const revision = typeof source === 'string' ? sha256(source) : null;
85
+ const parsed = parseProviderMap(source);
86
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision };
87
+ return {
88
+ ok: true,
89
+ schema: 'cordis-patch-llm-pi-ai-v1',
90
+ providerIds: parsed.entries.map((entry) => entry.id),
91
+ revision,
92
+ };
93
+ }
94
+
95
+ function scalarField(lines, entry, field) {
96
+ const pattern = new RegExp(`^\\s+${field}:\\s*(.*?)\\s*$`);
97
+ for (const line of lines.slice(entry.start, entry.end)) {
98
+ const match = line.match(pattern);
99
+ if (!match) continue;
100
+ const value = match[1].trim();
101
+ if (!value) return null;
102
+ return value.replace(/^(?:"([\\s\\S]*)"|'([\\s\\S]*)')$/, '$1$2');
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** Return profile provider provenance and credential reference names only. */
108
+ export function readProviderDeclarations(source, { file = 'profile.yml' } = {}) {
109
+ const parsed = parseProviderMap(source);
110
+ if (!parsed.ok) return { ok: false, code: parsed.code };
111
+ const declarations = parsed.entries.map((entry) => {
112
+ const displayName = scalarField(parsed.lines, entry, 'displayName');
113
+ const credentialRef = scalarField(parsed.lines, entry, 'apiKeyEnv');
114
+ return {
115
+ id: entry.id,
116
+ display_name: displayName ?? entry.id,
117
+ origin: 'profile-managed',
118
+ ownership: 'crew-managed-profile',
119
+ file,
120
+ ...(credentialRef ? { credential_ref: credentialRef } : {}),
121
+ };
122
+ });
123
+ return { ok: true, declarations };
124
+ }
125
+
126
+ /**
127
+ * Remove provider declarations from a known profile patch after an optional
128
+ * content-revision check. The returned text is safe to write atomically by the
129
+ * lifecycle layer; this pure helper never touches the filesystem.
130
+ */
131
+ export function removeProviderDeclarations(source, { providerIds = [], expectedRevision } = {}) {
132
+ const currentRevision = typeof source === 'string' ? sha256(source) : null;
133
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
134
+ return { ok: false, code: 'PROVIDER_PROFILE_CHANGED', revision: currentRevision };
135
+ }
136
+ const requested = [...new Set(providerIds.filter((id) => typeof id === 'string' && PROVIDER_ID.test(id)))];
137
+ if (requested.length === 0 || requested.length !== providerIds.length) {
138
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
139
+ }
140
+ const parsed = parseProviderMap(source);
141
+ if (!parsed.ok) return { ok: false, code: parsed.code, revision: currentRevision };
142
+ const byId = new Map(parsed.entries.map((entry) => [entry.id, entry]));
143
+ if (requested.some((id) => !byId.has(id))) {
144
+ return { ok: false, code: 'PROVIDER_NOT_FOUND', revision: currentRevision };
145
+ }
146
+
147
+ const removedSet = new Set(requested);
148
+ const remaining = parsed.entries.filter((entry) => !removedSet.has(entry.id)).map((entry) => entry.id);
149
+ const removeRanges = [];
150
+ if (remaining.length === 0) {
151
+ // Avoid leaving an empty `llm-pi-ai.config.providers` mapping that could
152
+ // fail the next profile parse. Remove the complete managed sequence item.
153
+ removeRanges.push([parsed.llmStart, parsed.blockEnd]);
154
+ } else {
155
+ for (const id of requested) {
156
+ const entry = byId.get(id);
157
+ removeRanges.push([entry.start, entry.end]);
158
+ }
159
+ }
160
+ const shouldRemove = (index) => removeRanges.some(([start, end]) => index >= start && index < end);
161
+ const lines = parsed.lines.filter((_line, index) => !shouldRemove(index));
162
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
163
+ const text = lines.join(newline);
164
+ return {
165
+ ok: true,
166
+ text,
167
+ removed: requested,
168
+ remaining,
169
+ revision: sha256(text),
170
+ };
171
+ }
@@ -13,8 +13,11 @@ export const READINESS_REASON_CODES = Object.freeze({
13
13
  HUB_INCOMPATIBLE: 'HUB_INCOMPATIBLE',
14
14
  PROVIDER_CATALOG_RESOLVED: 'PROVIDER_CATALOG_RESOLVED',
15
15
  PROVIDER_CATALOG_UNAVAILABLE: 'PROVIDER_CATALOG_UNAVAILABLE',
16
- PROVIDER_CATALOG_HEALTH_WARNING: 'PROVIDER_CATALOG_HEALTH_WARNING',
17
- PROVIDER_CATALOG_NOT_REQUIRED: 'PROVIDER_CATALOG_NOT_REQUIRED',
16
+ PROVIDER_CATALOG_HEALTH_WARNING: 'PROVIDER_CATALOG_HEALTH_WARNING',
17
+ PROVIDER_CATALOG_NOT_REQUIRED: 'PROVIDER_CATALOG_NOT_REQUIRED',
18
+ PROVIDER_LIFECYCLE_CONSISTENT: 'PROVIDER_LIFECYCLE_CONSISTENT',
19
+ PROVIDER_LIFECYCLE_INCONSISTENT: 'PROVIDER_LIFECYCLE_INCONSISTENT',
20
+ PROVIDER_INVENTORY_UNAVAILABLE: 'PROVIDER_INVENTORY_UNAVAILABLE',
18
21
  PROVIDER_MODE_UNKNOWN: 'PROVIDER_MODE_UNKNOWN',
19
22
  NO_CI_EVIDENCE: 'NO_CI_EVIDENCE',
20
23
  NO_EXECUTION_EVIDENCE: 'NO_EXECUTION_EVIDENCE',
@@ -29,10 +32,13 @@ const TARGET_ROWS = Object.freeze([
29
32
  ['hub_compatibility', 'live-runtime'],
30
33
  ['provider_catalog', 'live-runtime'],
31
34
  ['model_execution', 'real-execution'],
35
+ ['worker_primary_callable', 'real-execution'],
36
+ ['worker_escalation_callable', 'real-execution'],
37
+ ['reviewer_primary_callable', 'real-execution'],
38
+ ['provider_lifecycle_consistent', 'live-runtime'],
32
39
  ['deepseek_flash', 'real-execution'],
33
- ['deepseek_pro', 'real-execution'],
34
- ['opencode_go_mimo_qwen', 'real-execution'],
35
- ['reviewer_pipeline', 'real-execution'],
40
+ ['deepseek_pro', 'real-execution'],
41
+ ['reviewer_pipeline', 'real-execution'],
36
42
  ['cancellation_timeout_escalation', 'real-execution'],
37
43
  ['standalone_official', 'real-execution'],
38
44
  ]);
@@ -0,0 +1,29 @@
1
+ // Long-poll wait registration that removes itself: the terminal transition
2
+ // bulk-clears job.waiters, so a timed-out waiter that stayed registered would
3
+ // linger (and accumulate across polls) for the remaining lifetime of a
4
+ // long-running job. All three wait surfaces (hub, standalone jobs, workflow
5
+ // runtime) share this one implementation so the cleanup invariant cannot
6
+ // drift again.
7
+
8
+ /**
9
+ * Race the job's waiters against a timeout, always unregistering the waiter
10
+ * afterwards. `timeoutMs <= 0` means "no timeout": wait forever unless
11
+ * `immediateWithoutTimeout` is set, which resolves immediately (poll
12
+ * semantics, used by the hub route).
13
+ */
14
+ export async function raceWaiters(waiters, { timeoutMs = 0, immediateWithoutTimeout = false } = {}) {
15
+ let waiter = null;
16
+ let timer = null;
17
+ try {
18
+ await Promise.race([
19
+ new Promise((resolve) => { waiter = resolve; waiters.push(resolve); }),
20
+ timeoutMs > 0
21
+ ? new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })
22
+ : (immediateWithoutTimeout ? Promise.resolve() : new Promise(() => {})),
23
+ ]);
24
+ } finally {
25
+ const index = waiters.indexOf(waiter);
26
+ if (index !== -1) waiters.splice(index, 1);
27
+ if (timer !== null) clearTimeout(timer);
28
+ }
29
+ }