`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean.
215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { execa } from 'execa'
|
|
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
|
|
|
const POSIX_PTY_DRIVER = String.raw`
|
|
import errno, json, os, pty, select, signal, sys, time
|
|
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
|
env = os.environ.copy()
|
|
env.update(json.loads(launch_env_json))
|
|
env.update({"COLUMNS": "100", "LINES": "30"})
|
|
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
|
|
# banner to the per-letter gradient (one SGR per letter), breaking literal
|
|
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
|
|
env.pop("COLORTERM", None)
|
|
actions = json.loads(actions_json)
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.chdir(cwd)
|
|
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
|
|
|
output = bytearray()
|
|
action_index = 0
|
|
deadline = time.monotonic() + float(timeout_seconds)
|
|
status = None
|
|
while time.monotonic() < deadline:
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if ready:
|
|
try:
|
|
chunk = os.read(fd, 65536)
|
|
except OSError as error:
|
|
if error.errno != errno.EIO:
|
|
raise
|
|
chunk = b""
|
|
if chunk:
|
|
output.extend(chunk)
|
|
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
|
os.write(fd, actions[action_index]["send"].encode())
|
|
action_index += 1
|
|
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
|
if waited == pid:
|
|
status = candidate
|
|
break
|
|
|
|
if status is None:
|
|
os.kill(pid, signal.SIGKILL)
|
|
_, status = os.waitpid(pid, 0)
|
|
sys.stdout.buffer.write(output)
|
|
if action_index != len(actions):
|
|
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n")
|
|
sys.exit(124)
|
|
actual_exit = os.waitstatus_to_exitcode(status)
|
|
if actual_exit != int(expected_exit):
|
|
sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n")
|
|
sys.exit(125)
|
|
`
|
|
|
|
/** One terminal action sent after its marker has rendered. */
|
|
interface TuiPtyAction {
|
|
readonly waitFor: string
|
|
readonly send: string
|
|
}
|
|
|
|
/** Inputs for a keyless real-Loader TUI process smoke. */
|
|
export interface TuiPtySmokeOptions {
|
|
readonly label: string
|
|
readonly tempDirPrefix: string
|
|
readonly binScript: string
|
|
/** Config argument; ignored when {@link configArgs} is set. */
|
|
readonly configPath?: string
|
|
/** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */
|
|
readonly configArgs?: readonly string[]
|
|
readonly tsconfigPath: string
|
|
readonly actions?: readonly TuiPtyAction[]
|
|
readonly env?: Readonly<NodeJS.ProcessEnv>
|
|
readonly expectedExitCode?: number
|
|
readonly timeoutMs?: number
|
|
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
|
|
readonly prepare?: (cwd: string) => Promise<void>
|
|
/** Inspect the workspace after a passing run, before the temp dir is removed. */
|
|
readonly inspect?: (cwd: string) => Promise<void>
|
|
}
|
|
|
|
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
)
|
|
}
|
|
|
|
async function runPosixPtySmoke(
|
|
launch: ExampleLaunch,
|
|
cwd: string,
|
|
options: TuiPtySmokeOptions,
|
|
timeoutMs: number,
|
|
): Promise<string> {
|
|
// The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
|
|
// only backstops a wedged python3 process itself.
|
|
const result = await execa('python3', [
|
|
'-c',
|
|
POSIX_PTY_DRIVER,
|
|
launch.command,
|
|
JSON.stringify(launch.args),
|
|
JSON.stringify(launch.env),
|
|
cwd,
|
|
JSON.stringify(options.actions ?? []),
|
|
String(options.expectedExitCode ?? 0),
|
|
String(timeoutMs / 1_000),
|
|
], {
|
|
stdin: 'ignore',
|
|
timeout: timeoutMs + 5_000,
|
|
killSignal: 'SIGKILL',
|
|
reject: false,
|
|
stripFinalNewline: false,
|
|
})
|
|
if (result.timedOut) {
|
|
throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
|
}
|
|
if (result.failed) {
|
|
throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
|
}
|
|
return result.stdout
|
|
}
|
|
|
|
async function runWindowsPtySmoke(
|
|
launch: ExampleLaunch,
|
|
cwd: string,
|
|
options: TuiPtySmokeOptions,
|
|
timeoutMs: number,
|
|
): Promise<string> {
|
|
const pty = await import('node-pty')
|
|
return await new Promise((resolve, reject) => {
|
|
const actions = options.actions ?? []
|
|
const expectedExitCode = options.expectedExitCode ?? 0
|
|
let output = ''
|
|
let actionIndex = 0
|
|
let timedOut = false
|
|
const terminal = pty.spawn(launch.command, launch.args, {
|
|
name: 'xterm-256color',
|
|
cols: 100,
|
|
rows: 30,
|
|
cwd,
|
|
env: definedEnv({
|
|
...process.env,
|
|
...launch.env,
|
|
// Match the POSIX driver: no COLORTERM, so the banner never takes the
|
|
// truecolor gradient path under a developer's shell.
|
|
COLORTERM: undefined,
|
|
COLUMNS: '100',
|
|
LINES: '30',
|
|
}),
|
|
})
|
|
const timer = setTimeout(() => {
|
|
timedOut = true
|
|
terminal.kill()
|
|
}, timeoutMs)
|
|
terminal.onData((chunk) => {
|
|
output += chunk
|
|
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
|
terminal.write(actions[actionIndex]!.send)
|
|
actionIndex += 1
|
|
}
|
|
})
|
|
terminal.onExit(({ exitCode, signal }) => {
|
|
clearTimeout(timer)
|
|
if (timedOut) {
|
|
reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
|
|
} else if (actionIndex !== actions.length) {
|
|
reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
|
|
} else if (exitCode !== expectedExitCode) {
|
|
reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
|
|
} else {
|
|
resolve(output)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
|
|
* marker-gated input, and return captured bytes after the expected process exit.
|
|
* @param options - launch paths, environment, actions, and expected exit code.
|
|
* @returns complete pseudo-terminal output.
|
|
*/
|
|
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
|
|
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
|
const timeoutMs = options.timeoutMs ?? 25_000
|
|
try {
|
|
await options.prepare?.(cwd)
|
|
const launch = resolveExampleLaunch({
|
|
srcBin: options.binScript,
|
|
// `configPath` is the dsh `--config <path>` tree override; `configArgs`
|
|
// is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
|
|
configArgs: options.configArgs !== undefined
|
|
? [...options.configArgs]
|
|
/* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
|
|
: options.configPath !== undefined ? ['--config', options.configPath] : [],
|
|
tsconfigPath: options.tsconfigPath,
|
|
env: {
|
|
DSH_HOME: join(cwd, '.dsh'),
|
|
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
|
...options.env,
|
|
},
|
|
})
|
|
const output = process.platform === 'win32'
|
|
? await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
|
|
: await runPosixPtySmoke(launch, cwd, options, timeoutMs)
|
|
// Inspect the workspace before `finally` removes it (e.g. the session log).
|
|
await options.inspect?.(cwd)
|
|
return output
|
|
} finally {
|
|
await rm(cwd, { recursive: true, force: true })
|
|
}
|
|
}
|