feat(tui): add safe session resume flow
This commit is contained in:
@@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
|
||||
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ export function parseResumeArg(
|
||||
return { resumeSessionId, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
|
||||
* @param argv - current arguments after command dispatch.
|
||||
* @param sessionId - selected session id.
|
||||
* @returns flag-normalized arguments for a process replacement.
|
||||
*/
|
||||
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
|
||||
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
|
||||
const { rest } = parseResumeArg(argv)
|
||||
return [...rest, RESUME_FLAG, sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
@@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(
|
||||
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
|
||||
binName: string,
|
||||
absoluteConfigPath: string,
|
||||
patches?: PatchOptions[],
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await prepare?.(ctx)
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -55,6 +55,15 @@ describe('parseResumeArg', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceResumeArg', () => {
|
||||
it('keeps positional arguments and replaces either existing flag form', () => {
|
||||
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
@@ -196,6 +205,19 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs host preparation before the Loader tree mounts', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const prepared: Context[] = []
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
|
||||
try {
|
||||
expect(prepared).toEqual([ctx])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
|
||||
Reference in New Issue
Block a user