diff --git a/.github/workflows/dsh-candidate-source.yml b/.github/workflows/dsh-candidate-source.yml new file mode 100644 index 0000000..807e315 --- /dev/null +++ b/.github/workflows/dsh-candidate-source.yml @@ -0,0 +1,44 @@ +name: dsh-candidate-source + +on: + pull_request: + paths: + - compatibility-candidates.json + - compatibility.json + - packages/dsh/** + - packages/cli/** + - packages/model-knowledge/** + - scripts/qa-dsh-candidate-source.sh + - .github/workflows/dsh-candidate-source.yml + push: + branches: + - main + paths: + - compatibility-candidates.json + - compatibility.json + - packages/dsh/** + - packages/cli/** + - packages/model-knowledge/** + - scripts/qa-dsh-candidate-source.sh + - .github/workflows/dsh-candidate-source.yml + workflow_dispatch: + +concurrency: + group: dsh-candidate-source-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + candidate-source-graph: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6.1.0 + with: + version: 11.7.0 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: DSH candidate source-graph qualification + run: bash scripts/qa-dsh-candidate-source.sh diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 27afb50..bd275f0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -90,7 +90,11 @@ async function probeRuntime(): Promise<{ rows: Row[]; failed: boolean }> { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) + // Empty config is valid in both the verified rc.7 SystemPrompt generation + // (`persona?`) and current DSH (`personaPrefix?` / `personaSuffix?`). The + // doctor probe registers its scoped prompt section separately, so it does + // not need a deployment persona here. + await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/dsh/src/config-files.ts b/packages/dsh/src/config-files.ts index 3fcc138..4d0e33a 100644 --- a/packages/dsh/src/config-files.ts +++ b/packages/dsh/src/config-files.ts @@ -4,17 +4,20 @@ * * Precedence (tested): defaults → user → project → request → runtime * validation. The user layer comes from `ctx.settings` when a settings - * provider is composed (official settings-namespace seam); the project layer - * is the committed file; request layers come from the resolve call. + * provider is composed; the project layer is the committed file; request + * layers come from the resolve call. */ import { readFile } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import { loadPolicyLayers, type PolicyDocument, type PolicyLayerValue } from '@dshelm/core' -import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' export const DSHELM_CONFIG_DIR = '.dshelm' export const DSHELM_CONFIG_FILE = 'config.jsonc' -export const DSHELM_SETTINGS_NAMESPACE = settingsNamespace('dshelm') +// rc.7 exported settingsNamespace(); current DSH validates the literal inside +// SettingsProvider.register(). The branded literal works with both type surfaces +// without depending on the removed value export. +export const DSHELM_SETTINGS_NAMESPACE = 'dshelm' as SettingsNamespace /** Schema-backed user-level override document (optional fields). */ export interface DSHelmUserSettings { @@ -23,6 +26,32 @@ export interface DSHelmUserSettings { readonly categories?: Record } +/** + * Minimal callable schema accepted by both settings generations. The settings + * service has already merged its composition/user layers before invoking this + * function; full DSHelm policy validation remains owned by @dshelm/core. + * `toJSON` is the descriptor surface used by settings UIs. + */ +const dshelmUserSettingsSchema = Object.assign( + (value: unknown): DSHelmUserSettings => { + if (value === undefined) return {} + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('dshelm settings section must be an object') + } + return value as DSHelmUserSettings + }, + { + toJSON: () => ({ + type: 'object', + properties: { + profiles: { type: 'object' }, + agents: { type: 'object' }, + categories: { type: 'object' }, + }, + }), + }, +) + /** * Load the project layer from `/.dshelm/config.jsonc` when present. * A missing file yields `undefined` (no project override); malformed @@ -39,32 +68,34 @@ export async function loadProjectPolicyLayer(cwd: string): Promise DSHelmUserSettings | undefined { - let source: DSHelmUserSettings = base - installSettingsSection( - ctx, - DSHELM_SETTINGS_NAMESPACE, - // Minimal schema: free-form policy sections layered on the base document. - // Full schema validation happens in @dshelm/core at load time. - { profiles: { type: 'object' }, agents: { type: 'object' }, categories: { type: 'object' } } as never, - base, - { - setSource: (current) => { source = current() }, - onChange: () => {}, - }, - ) - return () => source + let current: () => DSHelmUserSettings = () => base + + ctx.inject(['settings'], (settingsCtx) => { + const scope = settingsCtx.settings.register( + DSHELM_SETTINGS_NAMESPACE, + dshelmUserSettingsSchema as never, + { base }, + ) + current = () => scope.get() as DSHelmUserSettings + settingsCtx.effect(() => () => { + current = () => base + }) + }) + + return () => current() } /** diff --git a/packages/dsh/src/projection-types.ts b/packages/dsh/src/projection-types.ts index eedd210..16d1c49 100644 --- a/packages/dsh/src/projection-types.ts +++ b/packages/dsh/src/projection-types.ts @@ -1,16 +1,31 @@ +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection/types' import type { ControlPlaneProjectionValue } from './session-events.ts' /** - * Host-only augmentation for the merge-extensible DSH projection map. + * Host-only augmentation for the merge-extensible DSH projection maps. * * Kept out of `session-events.ts` because that shared wire-types module is also - * compiled into the browser client declaration graph. Both rc.7 and 0.1.5 - * publish this augmentation target from `@deepseek-ai/dsh-session-projection/types`. + * compiled into the browser client declaration graph. rc.7 uses only + * `SessionProjectionMap`; current DSH separates internal fold state into + * `SessionProjectionStateMap`. Declaring both keeps one DSHelm projection key + * valid across the two generations without leaking host augmentation into the + * browser declaration graph. + * + * The explicit type-only imports are load-bearing: the root import activates + * DSH's `Context.sessionProjections` augmentation, while the `/types` import + * makes this declaration an augmentation of the published module rather than + * a new ambient module after `projection.ts` stopped importing the + * generation-specific `ProjectionDefinition` type. */ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { 'dshelm.controlPlane': ControlPlaneProjectionValue } + + interface SessionProjectionStateMap { + 'dshelm.controlPlane': ControlPlaneProjectionValue | undefined + } } export {} diff --git a/packages/dsh/src/projection.ts b/packages/dsh/src/projection.ts index fac7517..1c3c6fc 100644 --- a/packages/dsh/src/projection.ts +++ b/packages/dsh/src/projection.ts @@ -3,11 +3,10 @@ * * The host publishes whole-value `dshelm/control-plane` events; this unit * folds them into the `dshelm.controlPlane` projection key, which the - * official `session/projection` wire + client projection face deliver - * to the browser. Registered through `ctx.sessionProjections.register`. + * official session-projection wire + client projection face deliver to the + * browser. Registered through `ctx.sessionProjections.register`. */ import { z } from 'zod' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SessionEvent } from '@deepseek-ai/dsh-session' import './projection-types.ts' import type { ControlPlaneProjectionValue } from './session-events.ts' @@ -18,7 +17,7 @@ import type { ControlPlaneProjectionValue } from './session-events.ts' * The annotation is `z.ZodType`: zod's inferred * output spells optional fields as `T | undefined` while the interfaces use * exact optional properties, so the annotation is a variance-only cast — the - * runtime `parse` (the projection drive's only runtime consumer) is unchanged. + * runtime `parse` is unchanged. */ export const controlPlaneSchema: z.ZodType = z.object({ version: z.literal(1), @@ -60,23 +59,55 @@ export const controlPlaneSchema: z.ZodType = z.obje /** Internal fold state: the latest whole snapshot. */ type ControlPlaneState = ControlPlaneProjectionValue | undefined -export const dshelmControlPlaneProjection: ProjectionDefinition<'dshelm.controlPlane', ControlPlaneState> = { - key: 'dshelm.controlPlane', - schema: controlPlaneSchema, - init: () => undefined, - apply: (state, event: SessionEvent) => - event.type === 'dshelm/control-plane' ? event.data : state, - view: (state) => { - if (state === undefined) { - return { - version: 1, - request: { category: '' }, - roles: [], - inspector: { request: 'no-delegation', trace: { version: 1, request: { category: '' }, category: '', agent: '', profile: '', candidates: [], fields: [] } }, - source: 'projection:empty', - } +const controlPlaneStateSchema = controlPlaneSchema.optional() as unknown as z.ZodType + +function viewControlPlane(state: ControlPlaneState): ControlPlaneProjectionValue { + if (state === undefined) { + return { + version: 1, + request: { category: '' }, + roles: [], + inspector: { + request: 'no-delegation', + trace: { + version: 1, + request: { category: '' }, + category: '', + agent: '', + profile: '', + candidates: [], + fields: [], + }, + }, + source: 'projection:empty', } - return state + } + return state +} + +/** + * Dual-generation projection definition. + * + * rc.7 reads the top-level `schema` + `view` fields. Current DSH separates the + * persisted fold state (`stateSchema`) from the client wire surface (`wire`). + * Keeping both structural contracts on one object lets each registry consume + * only the fields it owns while the actual fold/view semantics stay identical. + */ +export const dshelmControlPlaneProjection = { + key: 'dshelm.controlPlane' as const, + // rc.7 projection contract. + schema: controlPlaneSchema, + view: viewControlPlane, + // Current projection contract. + stateSchema: controlPlaneStateSchema, + wire: { + viewSchema: controlPlaneSchema, + view: viewControlPlane, }, + // A no-argument initializer remains assignable to the current initializer, + // whose SessionHeader/inheritedEventCount inputs are not needed here. + init: (): ControlPlaneState => undefined, + apply: (state: ControlPlaneState, event: SessionEvent): ControlPlaneState => + event.type === 'dshelm/control-plane' ? event.data : state, stateVersion: 1, } diff --git a/scripts/qa-dsh-candidate-source.sh b/scripts/qa-dsh-candidate-source.sh new file mode 100644 index 0000000..8e7db00 --- /dev/null +++ b/scripts/qa-dsh-candidate-source.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +stage() { + printf '\n==> qa:dsh-candidate-source: %s\n' "$1" +} + +CANDIDATE_VERSION="$(node - <<'NODE' +const fs = require('node:fs') +const manifest = JSON.parse(fs.readFileSync('compatibility-candidates.json', 'utf8')) +const candidate = manifest.candidates.find((item) => item.runtime === '@deepseek-ai/dsh' && item.state !== 'ready') +if (!candidate?.version) throw new Error('no active @deepseek-ai/dsh candidate') +process.stdout.write(candidate.version) +NODE +)" +TESTED_VERSION="$(node -e "const c=require('./compatibility.json'); process.stdout.write(c.tested.dshPackages)")" + +if [[ "$CANDIDATE_VERSION" == "$TESTED_VERSION" ]]; then + echo "candidate must differ from tested baseline ($TESTED_VERSION)" >&2 + exit 1 +fi + +stage "prepare transient package graph for DSH $CANDIDATE_VERSION" +node - "$CANDIDATE_VERSION" <<'NODE' +const fs = require('node:fs') +const candidate = process.argv[2] + +function read(path) { return JSON.parse(fs.readFileSync(path, 'utf8')) } +function write(path, value) { fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) } +function pinDshDependencies(manifest) { + for (const bucket of ['dependencies', 'devDependencies', 'peerDependencies']) { + const deps = manifest[bucket] + if (!deps) continue + for (const name of Object.keys(deps)) { + if (name.startsWith('@deepseek-ai/dsh-')) deps[name] = candidate + } + } +} + +// Qualify the plugin inside the exact published DSH host cohort. Current DSH +// prerelease packages publish workspace peers as stable-looking ranges such as +// `>=0.1.5 <0.2.0-0`; those ranges do not themselves select 0.1.5-rc.1 from +// the `next` dist-tag. A real DSH installation provides those peers through +// the host package graph, so the transient workspace must model that host +// rather than asking pnpm to invent a future stable peer. +const rootPath = 'package.json' +const root = read(rootPath) +root.devDependencies ??= {} +root.devDependencies['@deepseek-ai/dsh'] = candidate +write(rootPath, root) + +const dshPath = 'packages/dsh/package.json' +const dsh = read(dshPath) +pinDshDependencies(dsh) +for (const obsolete of [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-slots', +]) delete dsh.dependencies?.[obsolete] +dsh.dependencies['@deepseek-ai/dsh-api-session-controller'] = candidate +dsh.dependencies['@deepseek-ai/dsh-client-ui-renderer'] = candidate +dsh.dsh.client.inject = [ + '@deepseek-ai/dsh-api-session-controller', + '@deepseek-ai/dsh-client-ui-renderer', +] +write(dshPath, dsh) + +const cliPath = 'packages/cli/package.json' +const cli = read(cliPath) +pinDshDependencies(cli) +// DSH 0.1.5's pi-ai adapter and upstream workspace are qualified on 0.85.1; +// DSHelm CLI directly consumes providers/all for OAuth discovery, so the +// transient graph aligns that direct dependency instead of testing a mixed +// 0.82.1/0.85.1 catalog generation. +cli.dependencies['@earendil-works/pi-ai'] = '0.85.1' +write(cliPath, cli) +NODE + +stage "seed exact candidate release-age exclusions" +# The repository intentionally reviews fresh dependencies before allowing them +# into the verified graph. This candidate lane has a different purpose: inspect +# the exact, explicitly selected same-day DSH prerelease. For package names that +# already carry a verified-version exclusion, pnpm's version-policy parser must +# see the two allowed versions as one disjunction; duplicate exact entries for +# the same package are not a stable multi-version representation. Keep both +# versions exact so this never becomes a wildcard approval for future releases. +node - "$TESTED_VERSION" "$CANDIDATE_VERSION" <<'NODE' +const fs = require('node:fs') +const [tested, candidate] = process.argv.slice(2) +const path = 'pnpm-workspace.yaml' +const lines = fs.readFileSync(path, 'utf8').split('\n') +const escaped = tested.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +const pattern = new RegExp(`^(\\s*-\\s*['\"]?)(@deepseek-ai/dsh-[^@'\"]+)@${escaped}(['\"]?\\s*)$`) +let rewritten = 0 +for (let i = 0; i < lines.length; i += 1) { + const match = lines[i].match(pattern) + if (!match) continue + lines[i] = `${match[1]}${match[2]}@${tested} || ${candidate}${match[3]}` + rewritten += 1 +} +if (rewritten === 0) throw new Error(`no verified DSH release-age exclusions found for ${tested}`) +fs.writeFileSync(path, lines.join('\n')) +console.log(`rewrote ${rewritten} DSH exclusions as exact ${tested} || ${candidate} disjunctions`) +NODE + +stage "resolve transient candidate graph" +# The committed lockfile and package manifests remain the verified rc.7 graph. +# This install mutates only the disposable Actions checkout. Lifecycle/native +# scripts are excluded: this lane proves package availability + source/type +# compatibility; clean runtime installation is a separate evidence gate. +pnpm install --no-frozen-lockfile --ignore-scripts + +stage "assert candidate graph is coherent" +node - "$CANDIDATE_VERSION" <<'NODE' +const fs = require('node:fs') +const candidate = process.argv[2] +const root = JSON.parse(fs.readFileSync('package.json', 'utf8')) +if (root.devDependencies?.['@deepseek-ai/dsh'] !== candidate) { + throw new Error(`root host=${root.devDependencies?.['@deepseek-ai/dsh']}, expected ${candidate}`) +} +for (const path of ['packages/dsh/package.json', 'packages/cli/package.json']) { + const manifest = JSON.parse(fs.readFileSync(path, 'utf8')) + for (const bucket of ['dependencies', 'devDependencies']) { + for (const [name, version] of Object.entries(manifest[bucket] ?? {})) { + if (name.startsWith('@deepseek-ai/dsh-') && version !== candidate) { + throw new Error(`${path}: ${name}=${version}, expected ${candidate}`) + } + } + } +} +const dsh = JSON.parse(fs.readFileSync('packages/dsh/package.json', 'utf8')) +if (dsh.dependencies['@deepseek-ai/dsh-client-runtime']) throw new Error('legacy dsh-client-runtime survived candidate transform') +const expectedInject = ['@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-client-ui-renderer'] +if (JSON.stringify(dsh.dsh.client.inject) !== JSON.stringify(expectedInject)) { + throw new Error(`unexpected current client inject graph: ${JSON.stringify(dsh.dsh.client.inject)}`) +} +NODE + +stage "typecheck candidate workspace" +pnpm typecheck +stage "build candidate workspace" +pnpm build +stage "run compatibility-sensitive contracts" +pnpm vitest run \ + packages/dsh/tests/dsh-request-contract.test.ts \ + packages/dsh/tests/host-composition.test.ts \ + packages/dsh/tests/vertical-slice.test.ts \ + packages/dsh/tests/keyless-vertical-slice.test.ts \ + packages/dsh/tests/session-log-compat.test.ts \ + packages/dsh/tests/subagent-015-compat.test.ts \ + packages/dsh/tests/client-native-panel.test.ts \ + packages/model-knowledge/tests/knowledge.test.ts \ + packages/cli/tests/user-commands.test.ts +stage "verify built client artifact" +node scripts/verify-client-bundle.js + +echo "qa:dsh-candidate-source OK (candidate $CANDIDATE_VERSION; tested baseline remains $TESTED_VERSION)"