feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.

- Extract the `Branded<B>` primitive into a new standalone type-only package
  `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
  dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
  dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
  dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
  generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
  the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
  the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
  SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
  that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
  agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
  at the config boundary and the inner create()/resume casts disappear (only the
  genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
  Map keys and public params/exports (SessionStore, AgentRegistry + factory
  options, the ACP session-id surface + ToolPresenter CallId map, the
  persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
  the Branded type-equiv at dsh-brand, fix stale param types in the session/
  agent/bash READMEs, regenerate the cordis catalog + module graph.

Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
This commit is contained in:
Tianyi Cui
2026-06-21 07:17:25 +08:00
parent 24168aee70
commit d6a2ab30c8
75 changed files with 644 additions and 445 deletions

View File

@@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
- `ctx.sessions.get(id: string): Session | undefined`
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives

View File

@@ -20,10 +20,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -220,7 +220,7 @@ export class Session {
* subscribe to `session/event` and flush on `session/flush` / dispose.
*/
export class SessionStore extends Service {
private store = new Map<string, Session>()
private store = new Map<SessionId, Session>()
private counter = 0
constructor(ctx: Context) {
@@ -244,7 +244,7 @@ export class SessionStore extends Service {
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
*/
create(id?: string, options?: CreateSessionOptions): Session {
create(id?: SessionId, options?: CreateSessionOptions): Session {
const session = this.prepare(id, options)
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
@@ -269,7 +269,7 @@ export class SessionStore extends Service {
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path.
*/
prepare(id?: string, options?: CreateSessionOptions): Session {
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd
@@ -321,7 +321,7 @@ export class SessionStore extends Service {
this.ctx.emit('session/created', session)
}
get(id: string): Session | undefined {
get(id: SessionId): Session | undefined {
return this.store.get(id)
}

View File

@@ -1,4 +1,5 @@
import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>

View File

@@ -213,11 +213,11 @@ describe('SessionStore', () => {
it('rejects duplicate ids and supports seeding', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const a = ctx.sessions.create('fixed')
expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
const forked = ctx.sessions.create('fork', { seed: [...a.events] })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
})
@@ -228,11 +228,11 @@ describe('SessionStore', () => {
// the REAL session, breaking the store-uniqueness invariant.
const ctx = new Context()
await ctx.plugin(SessionStore)
const stale = ctx.sessions.prepare('racy')
const live = ctx.sessions.create('racy')
const stale = ctx.sessions.prepare(SessionId('racy'))
const live = ctx.sessions.create(SessionId('racy'))
expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
// The live session is intact and still the store entry.
expect(ctx.sessions.get('racy')).toBe(live)
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
@@ -241,24 +241,24 @@ describe('SessionStore', () => {
const created: Session[] = []
ctx.on('session/created', session => void created.push(session))
const session = ctx.sessions.prepare('lifecycle')
const session = ctx.sessions.prepare(SessionId('lifecycle'))
// prepare alone does NOT enter the store.
expect(ctx.sessions.get('lifecycle')).toBeUndefined()
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
const detach = ctx.sessions.enter(session)
expect(ctx.sessions.get('lifecycle')).toBe(session)
expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
// enter does NOT announce.
expect(created).toEqual([])
ctx.sessions.announce(session)
expect(created).toEqual([session])
// The detach disposer removes the entry + stops notification.
detach()
expect(ctx.sessions.get('lifecycle')).toBeUndefined()
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('synthesizes a minimal v1 header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('plain')
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(session.header.cwd).toBeUndefined()
@@ -268,7 +268,7 @@ describe('SessionStore', () => {
it('attaches cwd and parentSession from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('child', {
const session = ctx.sessions.create(SessionId('child'), {
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
})
expect(session.header).toMatchObject({
@@ -282,10 +282,10 @@ describe('SessionStore', () => {
it('rejects a non-absolute meta.cwd', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
.toThrow(/cwd must be an absolute path/)
// the rejected session was not registered
expect(ctx.sessions.get('rel')).toBeUndefined()
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
})
it('a bare Session() constructed without the store still exposes a v1 header', () => {
@@ -300,15 +300,15 @@ describe('SessionStore', () => {
let session!: Session
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('scoped')
session = inner.sessions.create(SessionId('scoped'))
}, { inject: ['sessions'] }))
expect(ctx.sessions.get('scoped')).toBe(session)
expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
let observed = 0
ctx.on('session/event', () => void observed++)
await fiber.dispose()
expect(ctx.sessions.get('scoped')).toBeUndefined()
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
expect(observed).toBe(0)
})
@@ -323,15 +323,15 @@ describe('SessionStore', () => {
})
// The throwing emit must roll the store entry back, not leak it.
expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
// A subsequent create of the SAME id succeeds (the already-exists check is
// not wedged) and its onAppend is correctly wired (events observable).
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create('fixed')
expect(ctx.sessions.get('fixed')).toBe(session)
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
expect(events).toHaveLength(1)
})

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
}