Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/session-registry/session-registry-live/README.md
|
||||
README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040
|
||||
README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515
|
||||
32
packages/session-registry/session-registry-live/README.md
Normal file
32
packages/session-registry/session-registry-live/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# @deepseek-ai/dsh-session-registry-live
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front.
|
||||
|
||||
## Behavior
|
||||
|
||||
Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions.
|
||||
|
||||
A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show.
|
||||
|
||||
`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one.
|
||||
|
||||
Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune.
|
||||
|
||||
## Config
|
||||
|
||||
None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check.
|
||||
- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision.
|
||||
32
packages/session-registry/session-registry-live/README.zh.md
Normal file
32
packages/session-registry/session-registry-live/README.zh.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# @deepseek-ai/dsh-session-registry-live
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。
|
||||
|
||||
## 行为
|
||||
|
||||
注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。
|
||||
|
||||
会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。
|
||||
|
||||
`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。
|
||||
|
||||
发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。
|
||||
|
||||
## 配置
|
||||
|
||||
无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。
|
||||
- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。
|
||||
44
packages/session-registry/session-registry-live/package.json
Normal file
44
packages/session-registry/session-registry-live/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-registry-live",
|
||||
"description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-registry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry-file": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
84
packages/session-registry/session-registry-live/src/index.ts
Normal file
84
packages/session-registry/session-registry-live/src/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Publishes every live session in this process into the cross-process session
|
||||
* registry, so `dsh list-sessions` lists sessions a server creates on demand rather than
|
||||
* only the one a launcher minted up front.
|
||||
*
|
||||
* Mounted in a composition whose sessions come and go — the browser UI creates
|
||||
* one per conversation — this plugin follows `session/created` and
|
||||
* `session/disposed` instead of registering a single launcher-known identity.
|
||||
* A session with no `cwd` in its header is skipped: the registry's workspace
|
||||
* column would have nothing truthful to show, and a subagent child is exactly
|
||||
* that case. Titles are mirrored into the record as `session/title` events
|
||||
* arrive, so a reader never has to parse a backend's log format.
|
||||
* @module @deepseek-ai/dsh-session-registry-live
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the Context merges this plugin relies on: the
|
||||
// `sessionRegistry` service and the `session/title` session event.
|
||||
import type {} from '@deepseek-ai/dsh-session-registry'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'session-registry-live'
|
||||
|
||||
/** Services required before sessions can be followed and records published. */
|
||||
export const inject = ['sessions', 'sessionRegistry']
|
||||
|
||||
/**
|
||||
* Follow session lifecycle and keep the registry in step.
|
||||
* @param ctx - context carrying the session store and the registry service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
/**
|
||||
* Per-session registration state. `'disposing'` is a tombstone written when a
|
||||
* session ends while its registration is still in flight: without it the
|
||||
* late-arriving disposer would be stored for a session that no longer exists
|
||||
* and its record would outlive the session until a pid-based prune.
|
||||
*/
|
||||
const registered = new Map<Session, (() => Promise<void>) | 'disposing'>()
|
||||
|
||||
const publish = (session: Session): void => {
|
||||
const cwd = session.header.cwd
|
||||
// A session without a workspace has no listable location; skipping keeps the
|
||||
// registry free of rows `dsh list-sessions` could not render truthfully.
|
||||
if (cwd === undefined) return
|
||||
void ctx.sessionRegistry.register({ sessionId: session.id, cwd })
|
||||
.then((dispose) => {
|
||||
if (registered.get(session) === 'disposing') {
|
||||
registered.delete(session)
|
||||
void dispose()
|
||||
return
|
||||
}
|
||||
registered.set(session, dispose)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
registered.delete(session)
|
||||
ctx.logger.warn('failed to publish session %s: %s', session.id, String(error))
|
||||
})
|
||||
}
|
||||
|
||||
for (const session of ctx.sessions.list()) publish(session)
|
||||
ctx.on('session/created', (session) => { publish(session) }, { global: true })
|
||||
ctx.on('session/disposed', (session) => {
|
||||
const entry = registered.get(session)
|
||||
if (typeof entry === 'function') {
|
||||
registered.delete(session)
|
||||
void entry()
|
||||
return
|
||||
}
|
||||
// Registration is still in flight; leave a tombstone for it to observe.
|
||||
registered.set(session, 'disposing')
|
||||
}, { global: true })
|
||||
|
||||
// Mirror title revisions onto the record. A title arrives after registration
|
||||
// and may be replaced, so the listing tracks the latest logged value.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'session/title') return
|
||||
const { title } = event.data
|
||||
void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => {
|
||||
ctx.logger.warn('failed to retitle %s: %s', session.id, String(error))
|
||||
})
|
||||
}, { global: true })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`.
|
||||
* @module @deepseek-ai/dsh-session-registry-live/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-registry-live-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this plugin owns no durable state of its own — the
|
||||
* uniqueness and liveness relations over published records are checked by the
|
||||
* companion in `@deepseek-ai/dsh-session-registry`, which owns that file.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for the live-session publisher over the REAL session store, so
|
||||
* publication follows the store's actual lifecycle dispatch rather than a
|
||||
* hand-built event emitter: sessions created after mount are published,
|
||||
* disposal removes their records, a session without a workspace is skipped, and
|
||||
* logged title revisions are mirrored onto the record so a reader never parses a
|
||||
* backend's log format.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
|
||||
import * as live from '@deepseek-ai/dsh-session-registry-live'
|
||||
// Empty type import carries the `session/title` event into the session-event map.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) })
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** Mount the real store plus the publisher. */
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
await ctx.plugin(live)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Let the publisher's fire-and-forget registration reach durability. */
|
||||
const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 200) })
|
||||
|
||||
/** Read the registry through an independent service, as `dsh list-sessions` would. */
|
||||
async function listExternally(): Promise<SessionRegistryRecord[]> {
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
const records = await reader.sessionRegistry.list()
|
||||
await reader.fiber.dispose()
|
||||
return records
|
||||
}
|
||||
|
||||
describe('publishing', () => {
|
||||
it('publishes sessions that already exist when the plugin mounts', async () => {
|
||||
// A composition may mount the publisher after sessions exist (a resumed
|
||||
// session, or plugin order), so mount-time adoption is its own path.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } })
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
await ctx.plugin(live)
|
||||
await settle()
|
||||
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('publishes a session created after mount', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } })
|
||||
await settle()
|
||||
|
||||
const listed = await ctx.sessionRegistry.list()
|
||||
expect(listed).toHaveLength(1)
|
||||
expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips a session with no workspace, having nothing truthful to list', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('no-cwd'))
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('has no title until one is logged', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('mirrors the latest logged title onto the record', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } })
|
||||
await settle()
|
||||
|
||||
session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess')
|
||||
|
||||
// A revision replaces the previous value rather than accumulating.
|
||||
session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores session events other than a title revision', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } })
|
||||
await settle()
|
||||
const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle')
|
||||
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await settle()
|
||||
expect(retitle).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retitles only the session that logged the event', async () => {
|
||||
const ctx = await mount()
|
||||
const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } })
|
||||
ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } })
|
||||
await settle()
|
||||
|
||||
first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title]))
|
||||
expect(byId.get(SessionId('one'))).toBe('only mine')
|
||||
expect(byId.get(SessionId('two'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('publishes every concurrently created session', async () => {
|
||||
const ctx = await mount()
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } })
|
||||
}
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort())
|
||||
.toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure and race handling', () => {
|
||||
it('removes the record when a session is disposed mid-registration', async () => {
|
||||
// The tombstone path: the session ends before its registration resolves, so
|
||||
// the late disposer must be applied instead of stored for a dead session.
|
||||
const ctx = await mount()
|
||||
let owner: Context | undefined
|
||||
await ctx.plugin({
|
||||
inject: ['sessions'],
|
||||
apply: (child: Context) => {
|
||||
owner = child
|
||||
child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } })
|
||||
},
|
||||
})
|
||||
// No settle: dispose while `register` is still in flight.
|
||||
await owner?.fiber.dispose()
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('warns and drops the record when publication fails', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } })
|
||||
await settle()
|
||||
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('warns when a title revision cannot be recorded', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } })
|
||||
await settle()
|
||||
|
||||
ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('removes a record when its own session is disposed, keeping the others', async () => {
|
||||
const ctx = await mount()
|
||||
// A session belongs to the fiber that created it, so a child plugin fiber
|
||||
// gives one session an independent lifetime without disposing the services.
|
||||
let owner: Context | undefined
|
||||
await ctx.plugin({
|
||||
inject: ['sessions'],
|
||||
apply: (child: Context) => {
|
||||
owner = child
|
||||
child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } })
|
||||
},
|
||||
})
|
||||
ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } })
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
|
||||
|
||||
// Disposing only that fiber ends its session, which the publisher follows.
|
||||
await owner?.fiber.dispose()
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('leaves no record behind after the whole tree unloads', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } })
|
||||
ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } })
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
expect(await listExternally()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../session-registry"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user