Adapt settings client for DSH 0.2.0 configForms without breaking boot.

Soft-inject configForms/settingsScope and mark Config fields volatile so desktop 0.2.0 no longer waits forever on the removed settingsScope service.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-09 15:31:33 +08:00
parent a41a2b53c9
commit fcc8f75560
6 changed files with 117 additions and 44 deletions

View file

@ -36,7 +36,7 @@ Loader id:`dsh-cron-tasks`
`package.json` 必须同时有 `dsh.bundle.patch` 和 `dsh.client`(`platform: web`,`immediately: true`)。Host **不要** import `@deepseek-ai/*`,也不要把它写进 `dependencies`。工具用纯 `ToolDefinition`,不要 `defineTool`。
Client `inject` 只写 `slots` / `locale` / `settingsScope`。静态 `inject: ['sessions']` 会在 boot 死锁;`sessions` / `workspaces` 用 `ctx.inject` 可选 + `internal/service`。
Client 硬 `inject` 只写 `slots` / `locale`。不要硬注入 `settingsScope`:DSH ≥0.1.7 / 0.2.0 已删掉该服务(换成 `configForms`),硬等会让 web boot 卡在 pending。设置卡用软注入:`configForms` 优先,否则 `settingsScope`。静态 `inject: ['sessions']` 会在 boot 死锁;`sessions` / `workspaces` 用 `ctx.inject` 可选 + `internal/service`。
---

View file

@ -158,7 +158,9 @@ window.__ModuleLoader__.load({
const name = 'dsh-ops-cron'
const NS = 'dsh-ops-cron'
const inject = ['slots', 'locale', 'settingsScope']
// Hard-inject only services present on both DSH ≤0.1.5 and ≥0.1.7.
// Do NOT hard-inject settingsScope: DSH ≥0.1.7 / 0.2.0 removed it (configForms).
const inject = ['slots', 'locale']
const API = '/dsh-ops-cron'
function readCookie(name) {
@ -2222,19 +2224,43 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}, 'dsh-ops-cron: locale')
const scope = ctx.settingsScope.bind({ namespace: NS })
const form = createForm(scope)
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: NS,
locale: LOCALE_NS,
inject: () => ({
hooks: { card: form.store },
edit: (field, value) => form.edit(field, value),
save: () => { form.save() },
discard: () => form.discard(),
}),
}, SettingsCard))
// Soft-mount Plugins card: configForms (DSH ≥0.1.7 / 0.2.0) or settingsScope (≤0.1.5).
let settingsMounted = false
const mountSettingsCard = (scope) => {
if (settingsMounted || !scope) return
settingsMounted = true
const form = createForm(scope)
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: NS,
locale: LOCALE_NS,
inject: () => ({
hooks: { card: form.store },
edit: (field, value) => form.edit(field, value),
save: () => { form.save() },
discard: () => form.discard(),
}),
}, SettingsCard))
}
ctx.inject(['configForms'], (formsCtx) => {
const forms = formsCtx.configForms || formsCtx.get?.('configForms')
if (!forms || typeof forms.get !== 'function') {
formsCtx.logger?.warn?.('[dsh-ops-cron] configForms present but .get missing')
return
}
formsCtx.logger?.info?.('[dsh-ops-cron] settings card via configForms')
mountSettingsCard(forms.get(NS))
})
ctx.inject(['settingsScope'], (scopeCtx) => {
if (settingsMounted) return
const binder = scopeCtx.settingsScope || scopeCtx.get?.('settingsScope')
if (!binder || typeof binder.bind !== 'function') {
scopeCtx.logger?.warn?.('[dsh-ops-cron] settingsScope present but .bind missing')
return
}
scopeCtx.logger?.info?.('[dsh-ops-cron] settings card via settingsScope')
mountSettingsCard(binder.bind({ namespace: NS }))
})
const faces = {
sessions: undefined,

1
lib/index.d.ts vendored
View file

@ -10,6 +10,7 @@ export interface Config {
historyLimit?: number
overlapPolicy?: 'skip'
misfirePolicy?: 'skip'
allowExternalCwd?: boolean
}
export function apply(ctx: Context, config?: Config): void

View file

@ -109,13 +109,22 @@ function loadPkg(id) {
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
const Schema = SchemaMod.default || SchemaMod
/**
* DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the
* settings UI. Zero volatile fields → the whole entry is silently filtered.
* Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path).
*/
function vol(schema) {
return typeof schema?.volatile === 'function' ? schema.volatile() : schema
}
export const Config = Schema.object({
enabled: Schema.boolean().default(DEFAULT_SETTINGS.enabled),
timezone: Schema.string().default(DEFAULT_SETTINGS.timezone),
historyLimit: Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit),
overlapPolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy),
misfirePolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy),
allowExternalCwd: Schema.boolean().default(DEFAULT_SETTINGS.allowExternalCwd),
enabled: vol(Schema.boolean().default(DEFAULT_SETTINGS.enabled)),
timezone: vol(Schema.string().default(DEFAULT_SETTINGS.timezone)),
historyLimit: vol(Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit)),
overlapPolicy: vol(Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy)),
misfirePolicy: vol(Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy)),
allowExternalCwd: vol(Schema.boolean().default(DEFAULT_SETTINGS.allowExternalCwd)),
})
function resolveConfig(config = {}) {
@ -178,8 +187,60 @@ export function apply(ctx, config = {}) {
})
ctx.inject(['settings'], (sctx) => {
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
sctx.logger?.info?.(`[dsh-ops-cron] settings namespace "${NS}" registered`)
const settings = sctx.settings
// ≤0.1.5: explicit namespace registration. ≥0.1.7: Config is projected from
// the Loader entry — register may be absent or refuse; follow describe().
if (typeof settings?.register === 'function') {
try {
settings.register(NS, Config, { base: entry, applies: 'live' })
sctx.logger?.info?.(`[dsh-ops-cron] settings namespace "${NS}" registered`)
} catch (error) {
sctx.logger?.warn?.(
`[dsh-ops-cron] settings.register failed (will follow describe): ${error instanceof Error ? error.message : error}`,
)
}
}
const describeRows = () => {
try {
const raw = typeof settings?.describe === 'function' ? settings.describe() : null
if (Array.isArray(raw)) return raw
if (raw && typeof raw === 'object' && Array.isArray(raw.namespaces)) return raw.namespaces
} catch { /* settling */ }
return []
}
const readLive = () => {
const row = describeRows().find((item) => item && item.ns === NS)
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
return resolveConfig({ ...entry, ...row.value })
}
return entry
}
if (typeof settings?.watch === 'function') {
try {
const unwatch = settings.watch(NS, (next) => {
service.updateSettings(resolveConfig(next)).catch(() => {})
})
if (typeof unwatch === 'function') {
sctx.effect(() => () => unwatch(), 'dsh-ops-cron: settings.watch')
}
} catch {
// watch optional
}
} else {
// ≥0.1.7: no scope.watch — poll describe so UI writes reach the scheduler.
let lastFingerprint = JSON.stringify(readLive())
const timer = setInterval(() => {
const next = readLive()
const fingerprint = JSON.stringify(next)
if (fingerprint === lastFingerprint) return
lastFingerprint = fingerprint
service.updateSettings(next).catch(() => {})
}, 2_000)
sctx.effect(() => () => clearInterval(timer), 'dsh-ops-cron: settings describe poll')
sctx.logger?.info?.('[dsh-ops-cron] following settings via describe() (no watch — DSH ≥0.1.7 path)')
}
})
ctx.inject(['tools'], (tctx) => {
@ -193,23 +254,6 @@ export function apply(ctx, config = {}) {
}
})
ctx.effect(() => {
const off = ctx.on('internal/service', (serviceName, value) => {
if (serviceName !== 'settings' || !value?.watch) return
try {
const unwatch = value.watch(NS, (next) => {
service.updateSettings(resolveConfig(next)).catch(() => {})
})
if (typeof unwatch === 'function') {
// Replaced when the effect disposes.
}
} catch {
// watch is optional.
}
})
return () => off()
}, 'dsh-ops-cron: settings watch')
ctx.effect(() => registerWebRoute(ctx, (req, res) => {
service.handleRequest(req, res).catch(() => {
if (!res.headersSent) {

View file

@ -1,7 +1,7 @@
{
"name": "dsh-ops-cron",
"description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.",
"version": "0.1.15",
"version": "0.1.16",
"private": false,
"type": "module",
"repository": {
@ -52,8 +52,8 @@
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-settings-plugins",
"@deepseek-ai/dsh-client-ui-sidebar"
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-locale"
]
}
},

View file

@ -70,7 +70,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(client, /PresetField/)
assert.match(client, /agentPreset/)
assert.match(client, /mirrorToSession/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
assert.match(client, /inject = \['slots', 'locale'\]/)
assert.match(client, /configForms/)
assert.match(client, /settingsScope/)
assert.match(client, /locale\.bind/)
assert.match(client, /locale\/change/)
assert.match(client, /loginRequired/)