Merge pull request #2144 from deepseek-harness/worktree/fix-windows-preset-ci
fix(agent-presets): repair Windows preset CI
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6
|
||||
README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170
|
||||
README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d
|
||||
README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21
|
||||
@@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset
|
||||
|
||||
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
|
||||
|
||||
An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts.
|
||||
|
||||
### Display metadata
|
||||
|
||||
A preset may publish display text in an optional `preset.yml` beside its composition:
|
||||
|
||||
@@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
|
||||
|
||||
**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。
|
||||
|
||||
### 展示用元信息
|
||||
|
||||
preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* @module @deepseek-ai/dsh-agent-presets/mount
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { Include } from '@cordisjs/plugin-include'
|
||||
@@ -69,21 +70,25 @@ class PresetTree extends Include {
|
||||
* where Node's upward `node_modules` walk never reaches the harness's own
|
||||
* dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
|
||||
* mount records the host composition's base instead, which is inside the
|
||||
* installed harness, and bare names resolve from there.
|
||||
* installed harness, and bare names resolve from there. An absolute
|
||||
* filesystem path names neither base and becomes a file URL before Node's
|
||||
* ESM loader receives it, which is required for drive-letter paths on
|
||||
* Windows.
|
||||
* @param name - the module specifier from the row.
|
||||
* @param getOuterStack - the loader's stack composer for import diagnostics.
|
||||
* @returns the imported module, or the `cordis:` builtin.
|
||||
*/
|
||||
override import(name: string, getOuterStack?: () => string[]): unknown {
|
||||
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
|
||||
const base = harnessBase.get(this.config)
|
||||
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
|
||||
if (base === undefined) return super.import(name, getOuterStack)
|
||||
if (base === undefined) return super.import(specifier, getOuterStack)
|
||||
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
|
||||
const internal = this.ctx.loader.internal
|
||||
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
|
||||
hypothetical embedder from losing the row's name in a resolution error. */
|
||||
if (internal === undefined) return super.import(name, getOuterStack)
|
||||
return internal.import(name, base, {})
|
||||
if (internal === undefined) return super.import(specifier, getOuterStack)
|
||||
return internal.import(specifier, base, {})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,20 +65,23 @@ describe('copying a preset', () => {
|
||||
expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user')
|
||||
})
|
||||
|
||||
it('copies the whole directory, execute bits kept and group/other stripped', async () => {
|
||||
it('copies the whole directory and tightens POSIX modes', async () => {
|
||||
await seedPreset(userRoot, 'source', {
|
||||
extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' },
|
||||
})
|
||||
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
|
||||
}
|
||||
|
||||
await ctx.agentPresets.copy('source', 'mine')
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n')
|
||||
// A preset may ship runnable helpers; the copy keeps them runnable for the
|
||||
// owner while withdrawing the world-readability of the install.
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
|
||||
// Windows mode bits are synthetic and cannot represent the inherited DACL.
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the source description but never its name or order', async () => {
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const fsHarness = vi.hoisted(() => ({
|
||||
nextReadError: undefined as NodeJS.ErrnoException | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile: (async (path: unknown, ...rest: never[]) => {
|
||||
const error = fsHarness.nextReadError
|
||||
if (error !== undefined) {
|
||||
fsHarness.nextReadError = undefined
|
||||
throw error
|
||||
}
|
||||
return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
|
||||
}) as typeof actual.readFile,
|
||||
}
|
||||
})
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
||||
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
||||
|
||||
beforeEach(() => {
|
||||
fsHarness.nextReadError = undefined
|
||||
})
|
||||
|
||||
describe('display order', () => {
|
||||
it('puts declared order first, then everything else by id', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
|
||||
@@ -177,10 +200,11 @@ describe('composition health', () => {
|
||||
await mkdir(join(root, 'sealed'))
|
||||
const path = join(root, 'sealed', COMPOSITION_FILE)
|
||||
await writeFile(path, '[]\n')
|
||||
await chmod(path, 0o000)
|
||||
fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
expect(fsHarness.nextReadError).toBeUndefined()
|
||||
expect(preset?.broken).toMatch(/cannot be read/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
@@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentPresets, {
|
||||
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
@@ -84,6 +84,23 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
describe('composing an agent from a preset', () => {
|
||||
it('hands an absolute plugin path to Node as a file URL', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
|
||||
const presetDir = join(root, 'absolute')
|
||||
const plugin = join(FIXTURES, 'plugins', 'contribute.js')
|
||||
await mkdir(presetDir)
|
||||
await writeFile(
|
||||
join(presetDir, COMPOSITION_FILE),
|
||||
`- id: only\n name: ${plugin}\n config:\n tool: absolute\n`,
|
||||
)
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
|
||||
const imported = vi.spyOn(scoped.loader.internal!, 'import')
|
||||
|
||||
await agentOn(scoped, 'sess-absolute-plugin')
|
||||
|
||||
expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {})
|
||||
})
|
||||
|
||||
it('gives each session only its own preset\'s tools', async () => {
|
||||
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
|
||||
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
|
||||
@@ -525,6 +542,34 @@ describe('editing a composition file', () => {
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => {
|
||||
const { scoped, path } = await editable('guarded-refresh')
|
||||
const preset = await scoped.agentPresets.resolve('guarded-refresh')
|
||||
await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh')
|
||||
const service = scoped.agentPresets as unknown as {
|
||||
standing: Map<string, Promise<{
|
||||
key: unknown
|
||||
scope: unknown
|
||||
stamp: { mtimeMs: number; size: number }
|
||||
}>>
|
||||
ensureStanding(current: typeof preset): Promise<unknown>
|
||||
}
|
||||
const stalePromise = service.standing.get(preset.id)!
|
||||
const stale = await stalePromise
|
||||
await writeFile(path, rowFor('afterwards'))
|
||||
const { mtimeMs, size } = await stat(path)
|
||||
const newer = { ...stale, stamp: { mtimeMs, size } }
|
||||
const newerPromise = Promise.resolve(newer)
|
||||
|
||||
// `await pending` yields before the guarded delete, letting the winning
|
||||
// refresher replace the pointer deterministically instead of by timing.
|
||||
const refresh = service.ensureStanding(preset)
|
||||
service.standing.set(preset.id, newerPromise)
|
||||
|
||||
expect(await refresh).toBe(newer)
|
||||
expect(service.standing.get(preset.id)).toBe(newerPromise)
|
||||
})
|
||||
|
||||
it('hands a host reader the standing key without starting an agent', async () => {
|
||||
const { scoped } = await editable('cold-read')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user