@ran-sh/dsh-crew 1.1.1 → 1.2.0-rc.5

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 (84) hide show
  1. package/.claude-plugin/marketplace.json +16 -17
  2. package/.claude-plugin/plugin.json +7 -1
  3. package/README.md +44 -15
  4. package/README.zh.md +37 -12
  5. package/agents/ds-flash.md +16 -18
  6. package/agents/ds-pro.md +16 -24
  7. package/agents/ds-reviewer.md +15 -15
  8. package/agents/ds-worker.md +16 -16
  9. package/bin/dsh-crew.mjs +16 -12
  10. package/codex/AGENTS.md +37 -85
  11. package/codex/agents/ds-flash.toml +18 -14
  12. package/codex/agents/ds-pro.toml +18 -15
  13. package/codex/agents/ds-reviewer.toml +17 -13
  14. package/codex/agents/ds-worker.toml +17 -13
  15. package/codex/prompts/dsh-config.md +17 -1
  16. package/codex/prompts/dsh-status.md +5 -1
  17. package/commands/config.md +16 -4
  18. package/commands/status.md +6 -2
  19. package/docs/installation.md +54 -9
  20. package/docs/readiness-matrix.md +14 -6
  21. package/docs/ui-surfaces.md +32 -17
  22. package/lib/client.js +769 -177
  23. package/official-web-bridge/lib/client.js +297 -209
  24. package/official-web-bridge/overlay-entry.mjs +59 -0
  25. package/package.json +6 -3
  26. package/scripts/build-client.mjs +2 -0
  27. package/scripts/setup.mjs +8 -7
  28. package/scripts/verify-crew-ui-polish.mjs +145 -0
  29. package/scripts/verify-history-ui.mjs +97 -0
  30. package/scripts/verify-npm-install.mjs +1 -1
  31. package/src/client/history-panel.tsx +108 -0
  32. package/src/client/host-readiness.mjs +15 -6
  33. package/src/client/index.tsx +91 -69
  34. package/src/client/model-callability-view.mjs +17 -0
  35. package/src/client/panel-chrome.tsx +40 -0
  36. package/src/client/quick-panel.tsx +88 -73
  37. package/src/client/readiness-envelope.mjs +34 -0
  38. package/src/client/task-telemetry.mjs +11 -4
  39. package/src/config-readiness.mjs +181 -115
  40. package/src/extension-contract.mjs +44 -26
  41. package/src/history/admission-gate.mjs +51 -0
  42. package/src/history/archive-store.mjs +261 -0
  43. package/src/history/cleanup-plan.mjs +64 -0
  44. package/src/history/http.mjs +32 -0
  45. package/src/history/operation.mjs +86 -0
  46. package/src/history/runner.mjs +51 -0
  47. package/src/history/runtime.mjs +28 -0
  48. package/src/history/service.mjs +137 -0
  49. package/src/history/state.mjs +27 -0
  50. package/src/hub/index.mjs +141 -24
  51. package/src/hub-client.mjs +17 -15
  52. package/src/image-output.mjs +71 -0
  53. package/src/install/install-legacy.mjs +126 -23
  54. package/src/install/npx-lifecycle.mjs +373 -64
  55. package/src/install/official-frontend-assets.mjs +78 -0
  56. package/src/install/payload-content.mjs +88 -0
  57. package/src/install/windows-startup.mjs +107 -19
  58. package/src/install/windows-supervisor-adapter.mjs +443 -0
  59. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  60. package/src/install/zcode.mjs +53 -14
  61. package/src/job-contracts.mjs +37 -31
  62. package/src/jobs.mjs +2 -2
  63. package/src/mcp-runtime.mjs +74 -9
  64. package/src/model-callability-contract.mjs +79 -0
  65. package/src/multimodal.mjs +0 -0
  66. package/src/official-web-bridge.mjs +0 -2
  67. package/src/provider-health.mjs +130 -124
  68. package/src/readiness-matrix.mjs +20 -15
  69. package/src/runtime-identity-contract.mjs +34 -0
  70. package/src/runtime-identity.mjs +14 -4
  71. package/src/runtime-readiness-snapshot.mjs +285 -87
  72. package/src/server.mjs +86 -76
  73. package/src/standalone-sdk.mjs +23 -0
  74. package/src/supervisor/restart-request.mjs +64 -10
  75. package/src/workflow-runtime.mjs +28 -13
  76. package/windows/start-dsh-crew.cmd +57 -57
  77. package/windows/start-dsh-crew.ps1 +327 -51
  78. package/windows/supervisor-control.ps1 +467 -0
  79. package/zcode/AGENTS.md +4 -1
  80. package/zcode/agents/ds-reviewer.md +31 -36
  81. package/zcode/agents/ds-worker.md +31 -39
  82. package/zcode/commands/dsh-config.md +17 -6
  83. package/zcode/commands/dsh-status.md +5 -4
  84. package/.mcp.json +0 -8
@@ -3,7 +3,7 @@
3
3
  // The generated files make ZCode dispatch through the same dsh-crew MCP server
4
4
  // used by Codex and Claude while keeping ZCode's native config precedence.
5
5
 
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { dirname, join, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
@@ -62,8 +62,10 @@ function normalizePath(value) {
62
62
 
63
63
  function serverTarget(server) {
64
64
  if (!server || typeof server !== 'object') return null;
65
- if (server.command !== 'node' || !Array.isArray(server.args) || typeof server.args[0] !== 'string') return null;
66
- return normalizePath(server.args[0]);
65
+ if (server.command !== 'node' || !Array.isArray(server.args) || server.args.length !== 1 || typeof server.args[0] !== 'string') return null;
66
+ const target = normalizePath(server.args[0]);
67
+ if (!target) return null;
68
+ try { return statSync(target).isFile() ? target : null; } catch { return null; }
67
69
  }
68
70
 
69
71
  function nativeServers(config) {
@@ -250,13 +252,19 @@ function installTemplates({ home, root, priorFiles = [] }) {
250
252
  function updateMcp({ home, root }) {
251
253
  const source = configSource({ home });
252
254
  const target = expectedTarget({ root });
255
+ const normalizedTarget = normalizePath(target);
253
256
  const current = source.servers[SERVER];
254
257
  const currentTarget = serverTarget(current);
258
+ const currentDeclaredTarget = Array.isArray(current?.args) && typeof current.args[0] === 'string'
259
+ ? normalizePath(current.args[0])
260
+ : null;
255
261
  const owned = ownership({ home });
256
- if (current && currentTarget !== target) {
262
+ if (current && currentTarget !== normalizedTarget) {
257
263
  // Only an exact previously-owned entry may be repaired. Any foreign
258
264
  // command, even if it is also named dsh-crew, is a hard collision.
259
- if (!(owned && owned.config_file === source.file && normalizePath(owned.target) === currentTarget)) {
265
+ if (!(owned
266
+ && normalizePath(owned.config_file) === normalizePath(source.file)
267
+ && normalizePath(owned.target) === currentDeclaredTarget)) {
260
268
  return { ok: false, code: 'ZCODE_MCP_COLLISION', config_file: source.file };
261
269
  }
262
270
  }
@@ -303,22 +311,53 @@ function zcodeComponents({ home = homedir(), root = ROOT } = {}) {
303
311
  const source = configSource({ home });
304
312
  const configured = serverTarget(source.servers[SERVER]);
305
313
  const owned = ownership({ home });
314
+ const expectedPolicy = managedPolicyBlock(root);
315
+ const installedPolicy = readText(join(home, '.zcode', 'AGENTS.md'));
316
+ const templates = templateFiles({ home, root });
317
+ const templateReady = (entry) => {
318
+ if (!entry) return false;
319
+ const { source: sourceFile, dest } = entry;
320
+ const sourceText = readText(sourceFile);
321
+ const installedText = readText(dest);
322
+ return sourceText !== null && installedText === sourceText;
323
+ };
324
+ const templateBySuffix = (suffix) => templates.find(({ dest }) => dest.endsWith(suffix));
325
+ const ownershipFilesReady = !!owned && templates.every(({ source: sourceFile, dest }) => {
326
+ const expectedText = readText(sourceFile);
327
+ const installedText = readText(dest);
328
+ const record = Array.isArray(owned.files)
329
+ ? owned.files.find((entry) => normalizePath(entry?.path) === normalizePath(dest))
330
+ : null;
331
+ return expectedText !== null
332
+ && installedText === expectedText
333
+ && record?.managed_sha256 === contentHash(expectedText)
334
+ && contentHash(installedText) === record.managed_sha256;
335
+ });
306
336
  const components = {
307
337
  mcp: configured === expected,
308
- policy: typeof readText(join(home, '.zcode', 'AGENTS.md')) === 'string' && readText(join(home, '.zcode', 'AGENTS.md')).includes(POLICY_START),
309
- worker_agent: existsSync(join(home, '.zcode', 'agents', 'ds-worker.md')),
310
- reviewer_agent: existsSync(join(home, '.zcode', 'agents', 'ds-reviewer.md')),
311
- config_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-config.md')),
312
- status_prompt: existsSync(join(home, '.zcode', 'commands', 'dsh-status.md')),
313
- ownership: !!owned && owned.config_file === source.file && normalizePath(owned.target) === expected,
338
+ policy: !!expectedPolicy && typeof installedPolicy === 'string' && installedPolicy.includes(expectedPolicy),
339
+ worker_agent: templateReady(templateBySuffix(join('agents', 'ds-worker.md'))),
340
+ reviewer_agent: templateReady(templateBySuffix(join('agents', 'ds-reviewer.md'))),
341
+ config_prompt: templateReady(templateBySuffix(join('commands', 'dsh-config.md'))),
342
+ status_prompt: templateReady(templateBySuffix(join('commands', 'dsh-status.md'))),
343
+ ownership: !!owned
344
+ && normalizePath(owned.config_file) === normalizePath(source.file)
345
+ && normalizePath(owned.target) === expected
346
+ && ownershipFilesReady,
314
347
  };
315
- return { components, source, expected, configured, owned };
348
+ const policyFootprint = typeof installedPolicy === 'string'
349
+ && installedPolicy.includes(POLICY_START)
350
+ && installedPolicy.includes(POLICY_END);
351
+ const installed = Object.hasOwn(source.servers, SERVER)
352
+ || !!owned
353
+ || policyFootprint
354
+ || templates.some(({ dest }) => existsSync(dest));
355
+ return { components, source, expected, configured, owned, installed };
316
356
  }
317
357
 
318
358
  export function zcodeStatus({ home = homedir(), root = ROOT } = {}) {
319
- const { components, source, expected, configured } = zcodeComponents({ home, root });
359
+ const { components, source, expected, configured, installed } = zcodeComponents({ home, root });
320
360
  const missing = Object.entries(components).filter(([, value]) => !value).map(([key]) => key);
321
- const installed = Object.values(components).some(Boolean);
322
361
  return {
323
362
  installed,
324
363
  ready: missing.length === 0,
@@ -8,12 +8,12 @@
8
8
  export const JOB_CONTRACT_SCHEMA_VERSION = 1;
9
9
 
10
10
  export const JOB_EVENT_TYPES = Object.freeze([
11
- 'job.created',
12
- 'job.started',
13
- 'runtime.bound',
14
- 'model.selected',
15
- 'model.admitted',
16
- 'agent.created',
11
+ 'job.created',
12
+ 'job.started',
13
+ 'runtime.bound',
14
+ 'model.selected',
15
+ 'model.admitted',
16
+ 'agent.created',
17
17
  'model.fallback',
18
18
  'worker.started',
19
19
  'worker.completed',
@@ -82,6 +82,11 @@ function evidenceStatus(view) {
82
82
  if (view?.status === 'failed' || view?.phase === 'failed' || view?.outcome?.execution_status === 'failed') return 'FAIL';
83
83
  if (view?.review?.verdict === 'request_changes' || view?.outcome?.task_status === 'partial') return 'PARTIAL';
84
84
  if (view?.outcome?.task_status === 'blocked') return 'BLOCKED';
85
+ if (view?.role === 'reviewer' && view?.outcome == null) {
86
+ return view.status === 'done' && view.review?.status === 'done'
87
+ && view.review.verdict === 'approve' && view.review.delivery_complete === true
88
+ && view.review.mutated_candidate !== true ? 'PASS' : 'PARTIAL';
89
+ }
85
90
  if (view?.status === 'done' && view?.outcome?.task_status === 'success') return 'PASS';
86
91
  return 'PARTIAL';
87
92
  }
@@ -122,7 +127,7 @@ function compactSelectionTrace(attempts) {
122
127
  });
123
128
  }
124
129
 
125
- function changedFilesFromView(view) {
130
+ function changedFilesFromView(view) {
126
131
  if (Array.isArray(view?.candidate?.changed_files)) return view.candidate.changed_files;
127
132
  const changes = view?.workspace_diff?.changes;
128
133
  if (!changes || typeof changes !== 'object') return [];
@@ -132,24 +137,24 @@ function changedFilesFromView(view) {
132
137
  ...(Array.isArray(changes.renamed) ? changes.renamed : []),
133
138
  ...(Array.isArray(changes.untracked) ? changes.untracked : []),
134
139
  ])];
135
- }
136
-
137
- function boundedExecutionContext(value) {
138
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
139
- const executionPlane = boundedText(value.execution_plane, 64);
140
- const profile = boundedText(value.profile, 64);
141
- const runtimeId = boundedText(value.runtime_id, 128);
142
- const port = Number.isInteger(value.listen_port) ? value.listen_port : null;
143
- const ingress = value.ingress === 'official-3080' || value.ingress === 'direct-3210' ? value.ingress : null;
144
- if (!executionPlane && !profile && !runtimeId && port === null && !ingress) return null;
145
- return {
146
- execution_plane: executionPlane,
147
- profile,
148
- listen_port: port,
149
- runtime_id: runtimeId,
150
- ...(ingress ? { ingress } : {}),
151
- };
152
- }
140
+ }
141
+
142
+ function boundedExecutionContext(value) {
143
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
144
+ const executionPlane = boundedText(value.execution_plane, 64);
145
+ const profile = boundedText(value.profile, 64);
146
+ const runtimeId = boundedText(value.runtime_id, 128);
147
+ const port = Number.isInteger(value.listen_port) ? value.listen_port : null;
148
+ const ingress = value.ingress === 'official-3080' || value.ingress === 'direct-3210' ? value.ingress : null;
149
+ if (!executionPlane && !profile && !runtimeId && port === null && !ingress) return null;
150
+ return {
151
+ execution_plane: executionPlane,
152
+ profile,
153
+ listen_port: port,
154
+ runtime_id: runtimeId,
155
+ ...(ingress ? { ingress } : {}),
156
+ };
157
+ }
153
158
 
154
159
  /**
155
160
  * Build the machine-first Result Contract for a workflow.
@@ -165,6 +170,7 @@ export function buildEvidenceEnvelope(view = {}) {
165
170
  const errorMessage = boundedText(view.error, 1000);
166
171
  const changedFiles = boundedStrings(changedFilesFromView(view), { count: 120, length: 500 });
167
172
  const status = evidenceStatus(view);
173
+ const reviewOnly = view.role === 'reviewer' && view.outcome == null;
168
174
  const selectionAttempts = Array.isArray(view.child_attempts) && view.child_attempts.length > 0
169
175
  ? view.child_attempts
170
176
  : (view.selection_trace || view.provider || view.model ? [view] : []);
@@ -176,18 +182,18 @@ export function buildEvidenceEnvelope(view = {}) {
176
182
  status,
177
183
  summary: {
178
184
  phase: view.phase ?? null,
179
- task_status: outcome.task_status ?? null,
180
- execution_status: outcome.execution_status ?? null,
185
+ task_status: outcome.task_status ?? (reviewOnly && status === 'PASS' ? 'success' : null),
186
+ execution_status: outcome.execution_status ?? (reviewOnly && review?.status === 'done' ? 'completed' : null),
181
187
  tests_status: outcome.tests_status ?? null,
182
- delivery_complete: outcome.delivery?.complete === true,
188
+ delivery_complete: reviewOnly ? review?.delivery_complete === true : outcome.delivery?.complete === true,
183
189
  review_verdict: review?.verdict ?? null,
184
190
  },
185
- selection_trace: compactSelectionTrace(selectionAttempts),
186
- execution_context: boundedExecutionContext(view.execution_context ?? selectionAttempts[0]?.execution_context),
191
+ selection_trace: compactSelectionTrace(selectionAttempts),
192
+ execution_context: boundedExecutionContext(view.execution_context ?? selectionAttempts[0]?.execution_context),
187
193
  changed_files: changedFiles,
188
194
  changes: boundedStrings(outcome.changes),
189
195
  tests: boundedTests(outcome.tests),
190
- risks: boundedStrings(outcome.risks),
196
+ risks: boundedStrings(reviewOnly ? review?.risks : outcome.risks),
191
197
  unverified: boundedStrings(outcome.unverified),
192
198
  review: review ? {
193
199
  verdict: review.verdict ?? null,
package/src/jobs.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // never passes an explicit dshBin that would bypass it. The worker rides the
9
9
  // `sdk-minimal` profile with the Crew overlay applied as a --patch.
10
10
 
11
- import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client';
11
+ import { createStandaloneHarness } from './standalone-sdk.mjs';
12
12
  import { readFileSync, mkdirSync, existsSync } from 'node:fs';
13
13
  import { createShardWriter } from './status-shard.mjs';
14
14
  import { fileURLToPath } from 'node:url';
@@ -123,7 +123,7 @@ export function startJob({
123
123
  throw new Error(`DEEPSEEK_API_KEY not found in env or ${join(CONFIG_DIR, '.env')}`);
124
124
  }
125
125
 
126
- const harness = new DeepSeekHarness({
126
+ const harness = createStandaloneHarness({
127
127
  profile: 'sdk-minimal',
128
128
  patches: [CORDIS],
129
129
  dshHome: CREW_DSH_HOME,
@@ -8,7 +8,7 @@
8
8
  // live entirely in workflow-runtime.mjs; the adapters here just execute.
9
9
 
10
10
  import { createWorkflowRuntime } from './workflow-runtime.mjs';
11
- import { normalizeGlobalConfig } from './policy.mjs';
11
+ import { CONFIG_SCHEMA_VERSION, normalizeGlobalConfig } from './policy.mjs';
12
12
  import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
13
13
  import {
14
14
  createIsolatedWorkspace,
@@ -43,7 +43,72 @@ export function buildEffectiveRuntimeConfig(globalRaw = {}, session = {}) {
43
43
  if (session?.[key] !== undefined) patch[key] = session[key];
44
44
  }
45
45
  if (session?.enabled === false) patch.subagents_enabled = false;
46
- return normalizeGlobalConfig({ ...globalRaw, ...patch });
46
+ const base = normalizeGlobalConfig(globalRaw);
47
+ if (Object.keys(patch).length === 0) return base;
48
+
49
+ // Session controls are expressed through the compatibility vocabulary,
50
+ // but a persisted schema-v4 config correctly ignores flat compatibility
51
+ // mirrors. Project the session once through the legacy normalizer, then
52
+ // apply only the policy dimensions the session is allowed to own onto the
53
+ // canonical snapshot. Model priorities, provider routing, fallback,
54
+ // adaptive ordering, health gates, and review gate remain untouched.
55
+ const compatibility = { ...base, ...patch, config_schema_version: 2 };
56
+ delete compatibility.worker;
57
+ delete compatibility.review;
58
+ delete compatibility.execution;
59
+ delete compatibility.legacy;
60
+ // Canonical flat mirrors describe the persisted base and must not outrank
61
+ // this session's compatibility command. Remove the derived role/review
62
+ // mirrors so the legacy projector can recompute them from the requested
63
+ // session mode and review opt-in.
64
+ delete compatibility.worker_state;
65
+ delete compatibility.review_state;
66
+ delete compatibility.auto_review;
67
+ if (patch.collaboration_mode !== undefined) {
68
+ delete compatibility.tier_policy;
69
+ delete compatibility.flash_state;
70
+ delete compatibility.pro_state;
71
+ } else if (patch.tier_policy !== undefined) {
72
+ delete compatibility.collaboration_mode;
73
+ delete compatibility.flash_state;
74
+ delete compatibility.pro_state;
75
+ } else if (patch.flash_state !== undefined || patch.pro_state !== undefined) {
76
+ compatibility.collaboration_mode = 'custom';
77
+ delete compatibility.tier_policy;
78
+ }
79
+ Object.assign(compatibility, patch);
80
+ const projected = normalizeGlobalConfig(compatibility);
81
+ return normalizeGlobalConfig({
82
+ ...base,
83
+ config_schema_version: CONFIG_SCHEMA_VERSION,
84
+ subagents_enabled: projected.subagents_enabled,
85
+ main_agent_mode: projected.main_agent_mode,
86
+ execution: {
87
+ ...base.execution,
88
+ enabled: projected.subagents_enabled,
89
+ default_effort: projected.execution.default_effort,
90
+ default_timeout_seconds: projected.execution.default_timeout_seconds,
91
+ mode: projected.execution.mode,
92
+ },
93
+ worker: {
94
+ ...base.worker,
95
+ state: projected.worker.state,
96
+ model_policy: {
97
+ ...base.worker.model_policy,
98
+ strategy: projected.worker.model_policy.strategy,
99
+ escalation: {
100
+ ...base.worker.model_policy.escalation,
101
+ enabled: projected.worker.model_policy.escalation.enabled,
102
+ },
103
+ },
104
+ },
105
+ review: {
106
+ ...base.review,
107
+ state: projected.review.state,
108
+ auto_review: projected.review.auto_review,
109
+ },
110
+ legacy: projected.legacy,
111
+ });
47
112
  }
48
113
 
49
114
  /**
@@ -69,9 +134,9 @@ export function attemptFromView(view, spec) {
69
134
  modelClassHint: spec.model_class_hint ?? null,
70
135
  strategy: source ?? 'transport-selection',
71
136
  candidateSet: logicalAttempt > 0 ? 'escalation' : 'primary',
72
- provider: view?.provider ?? null,
73
- model: view?.model ?? null,
74
- execution_context: view?.execution_context ?? null,
137
+ provider: view?.provider ?? null,
138
+ model: view?.model ?? null,
139
+ execution_context: view?.execution_context ?? null,
75
140
  source: source ?? 'transport-selection',
76
141
  escalationReason: spec.escalation_reason ?? null,
77
142
  });
@@ -87,10 +152,10 @@ export function attemptFromView(view, spec) {
87
152
  // `view.attempt` may be an adapter routing-attempt (see below); the
88
153
  // workflow's logical attempt number is always the spec value.
89
154
  attempt: logicalAttempt,
90
- provider: view?.provider ?? null,
91
- model: view?.model ?? null,
92
- execution_context: view?.execution_context ?? null,
93
- selection_source: source,
155
+ provider: view?.provider ?? null,
156
+ model: view?.model ?? null,
157
+ execution_context: view?.execution_context ?? null,
158
+ selection_source: source,
94
159
  selection_trace: selectionTrace,
95
160
  status: view?.status ?? 'failed',
96
161
  result: view?.result ?? null,
@@ -0,0 +1,79 @@
1
+ import { isExactNativeCrewIdentity, sameCompleteRuntimeIdentity } from './runtime-identity-contract.mjs';
2
+
3
+ export const MODEL_CALLABILITY_SCHEMA_VERSION = 2;
4
+ const ROLES = ['worker', 'reviewer'];
5
+ const STATES = new Set(['CALLABLE', 'NOT_CALLABLE', 'STALE', 'UNKNOWN', 'NOT_APPLICABLE']);
6
+ const SOURCES = new Set(['provider_health', 'execution']);
7
+ const MAX_EVIDENCE_TTL_MS = 24 * 60 * 60 * 1000;
8
+
9
+ function exactKeys(value, keys) {
10
+ return value && typeof value === 'object' && !Array.isArray(value)
11
+ && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
12
+ }
13
+
14
+ function route(value) {
15
+ return typeof value?.provider === 'string' && value.provider.trim()
16
+ && typeof value?.model === 'string' && value.model.trim()
17
+ ? { provider: value.provider.trim(), model: value.model.trim() } : null;
18
+ }
19
+
20
+ function validEvidence(role, now) {
21
+ if (!SOURCES.has(role.source) || !Number.isFinite(role.observed_at) || role.observed_at > now
22
+ || !Number.isFinite(role.expires_at) || role.expires_at <= now
23
+ || role.expires_at - role.observed_at > MAX_EVIDENCE_TTL_MS) return false;
24
+ if (role.source === 'execution') {
25
+ if (!role.last_success || typeof role.last_success !== 'object'
26
+ || typeof role.last_success.job_id !== 'string' || !role.last_success.job_id.trim()
27
+ || role.last_success.observed_at !== role.observed_at
28
+ || role.last_success.expires_at !== role.expires_at) return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function validateModelCallabilityV2({ projection, runtime, expectedEnabledRoles, expectedSelections, now = Date.now() } = {}) {
34
+ const fail = (reason_code) => ({ ok: false, reason_code, state: 'UNKNOWN' });
35
+ if (!projection || typeof projection !== 'object' || projection.schema_version !== MODEL_CALLABILITY_SCHEMA_VERSION) return fail('MODEL_CALLABILITY_SCHEMA_INVALID');
36
+ if (!isExactNativeCrewIdentity(runtime) || !isExactNativeCrewIdentity(projection.runtime_identity)
37
+ || !sameCompleteRuntimeIdentity(runtime, projection.runtime_identity)
38
+ || projection.current_runtime_id !== runtime.runtime_id) return fail('MODEL_CALLABILITY_RUNTIME_MISMATCH');
39
+ if (!Number.isFinite(projection.captured_at) || projection.captured_at > now) return fail('MODEL_CALLABILITY_CAPTURE_INVALID');
40
+ if (!exactKeys(projection.enabled_roles, ROLES) || ROLES.some((role) => typeof projection.enabled_roles[role] !== 'boolean')) return fail('MODEL_CALLABILITY_ROLES_INVALID');
41
+ if (expectedEnabledRoles && (!exactKeys(expectedEnabledRoles, ROLES) || ROLES.some((role) => projection.enabled_roles[role] !== expectedEnabledRoles[role]))) return fail('MODEL_CALLABILITY_ROLE_CONFIG_MISMATCH');
42
+ if (expectedSelections && !exactKeys(expectedSelections, ROLES)) return fail('MODEL_CALLABILITY_EXPECTED_SELECTIONS_INVALID');
43
+ if (!exactKeys(projection.roles, ROLES)) return fail('MODEL_CALLABILITY_ROLE_EVIDENCE_MISSING');
44
+
45
+ for (const roleName of ROLES) {
46
+ const role = projection.roles[roleName];
47
+ if (!role || !STATES.has(role.state)) return fail('MODEL_CALLABILITY_ROLE_STATE_INVALID');
48
+ const selected = route(role.selected);
49
+ if (role.selected !== undefined && role.selected !== null && !selected) return fail('MODEL_CALLABILITY_SELECTION_INVALID');
50
+ if (expectedSelections) {
51
+ const expectedRaw = expectedSelections[roleName];
52
+ const expected = expectedRaw === null ? null : route(expectedRaw);
53
+ if (expectedRaw !== null && !expected) return fail('MODEL_CALLABILITY_EXPECTED_SELECTION_INVALID');
54
+ if ((expected === null) !== (selected === null)
55
+ || expected && (expected.provider !== selected.provider || expected.model !== selected.model)) return fail('MODEL_CALLABILITY_SELECTION_MISMATCH');
56
+ }
57
+ if (projection.enabled_roles[roleName] === false) {
58
+ if (role.state !== 'NOT_APPLICABLE' || role.reason_code !== 'ROLE_DISABLED') return fail('MODEL_CALLABILITY_DISABLED_ROLE_INVALID');
59
+ continue;
60
+ }
61
+ if (['CALLABLE', 'NOT_CALLABLE'].includes(role.state) && !selected) return fail('MODEL_CALLABILITY_SELECTION_INVALID');
62
+ if (projection.enabled_roles[roleName] === true && role.state === 'NOT_APPLICABLE') return fail('MODEL_CALLABILITY_ENABLED_ROLE_NOT_APPLICABLE');
63
+ if (['CALLABLE', 'NOT_CALLABLE'].includes(role.state)) {
64
+ if (!validEvidence(role, now)) return fail('MODEL_CALLABILITY_EVIDENCE_INVALID');
65
+ }
66
+ }
67
+
68
+ const activeRoles = ROLES.map((role) => projection.roles[role]).filter((_, index) => projection.enabled_roles[ROLES[index]] === true);
69
+ const activeStates = activeRoles.map((role) => role.state);
70
+ const state = activeStates.includes('NOT_CALLABLE') ? 'NOT_CALLABLE'
71
+ : activeStates.length > 0 && activeStates.every((value) => value === 'CALLABLE') ? 'CALLABLE'
72
+ : activeStates.includes('STALE') ? 'STALE' : 'UNKNOWN';
73
+ if (projection.overall !== state) return fail('MODEL_CALLABILITY_OVERALL_CONTRADICTION');
74
+ const evidenceExpirations = activeRoles.map((role) => role.expires_at).filter((value) => Number.isFinite(value));
75
+ const expectedExpires = evidenceExpirations.length ? Math.min(...evidenceExpirations) : null;
76
+ if (projection.expires_at !== expectedExpires) return fail('MODEL_CALLABILITY_EXPIRY_CONTRADICTION');
77
+ if (state === 'CALLABLE' && (!Number.isFinite(projection.expires_at) || projection.expires_at <= now)) return fail('MODEL_CALLABILITY_EXPIRED');
78
+ return { ok: true, state, reason_code: 'MODEL_CALLABILITY_VALID' };
79
+ }
Binary file
@@ -429,8 +429,6 @@ export function registerOfficialWebBridge(ctx, options = {}) {
429
429
  const QUICK_ALLOWLIST = new Set([
430
430
  '/quick-config',
431
431
  '/quick-status',
432
- '/runtime/restart-request',
433
- '/runtime/restart-status',
434
432
  ]);
435
433
  const disposers = [];
436
434
  for (const path of QUICK_ALLOWLIST) {