mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(workspace-controller): expose directory picking through Remote
This commit is contained in:
@@ -76,11 +76,6 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/api/workspace-controller": {
|
||||
"ignoreDependencies": [
|
||||
"zod"
|
||||
]
|
||||
},
|
||||
"packages/client/ui-approval": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.tsx"
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
@@ -87,6 +88,7 @@
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Host directory-picking Remote owner: capability gating, cancellation, and the
|
||||
* stable wire failure vocabulary over the `ctx.directoryPicker` seam.
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
// The seam owns the listing declaration; the generator requires the reference
|
||||
// site to name that package rather than this package's re-export of it.
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { DirectoryPickerErrorDetailsMap } from './types.ts'
|
||||
|
||||
const createDirectoryRequestSchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
}).refine(
|
||||
request => request.name.trim() !== '' && request.name !== '.' && request.name !== '..'
|
||||
&& !/[/\\]/.test(request.name),
|
||||
{ message: 'host.createDirectory requires a single non-blank path segment name' },
|
||||
)
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host directory-picking Remote namespace owner. */
|
||||
directoryPickerController: DirectoryPickerController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host service backing the generated `ctx.remote.directoryPicker` namespace. The
|
||||
* seam it exports is abstract and therefore never a Loader entry of its own, so
|
||||
* this controller carries the wire verbs: one composed backend serves either the
|
||||
* native chooser or the browse primitives, and a verb the composition cannot
|
||||
* serve is refused rather than approximated.
|
||||
*/
|
||||
export class DirectoryPickerController extends TypertRemoteService {
|
||||
static inject = ['directoryPicker']
|
||||
|
||||
/** @param ctx - Host context carrying the composed directory-picking backend. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'directoryPickerController', { namespace: 'directoryPicker' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the host's OS chooser for a Remote caller.
|
||||
* @param signal - caller lifetime; abort terminates the chooser.
|
||||
* @returns the chosen absolute path, or null when the operator cancels.
|
||||
*/
|
||||
@Remote('pick')
|
||||
async pick(signal: AbortSignal): Promise<string | null> {
|
||||
const capability = this.requireCapability('native', 'pick')
|
||||
try {
|
||||
return await capability.pick(signal)
|
||||
} catch (error: unknown) {
|
||||
throw cancellableFailure(error, signal, 'directory picker was aborted', 'directory picker failed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List one directory level for a Remote caller's in-app browser.
|
||||
* @param path - absolute directory to list; absent lists the home directory.
|
||||
* @param signal - caller lifetime; abort stops the backend's scan instead of
|
||||
* letting it outlive a disconnected caller.
|
||||
* @returns the level's listing with its ancestry.
|
||||
*/
|
||||
@Remote('list')
|
||||
async list(path: string | undefined, signal: AbortSignal): Promise<DirectoryListing> {
|
||||
const capability = this.requireCapability('browse', 'list')
|
||||
try {
|
||||
return await capability.list(path, signal)
|
||||
} catch (error: unknown) {
|
||||
throw cancellableFailure(error, signal, 'directory listing was aborted')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one child directory for a Remote caller's in-app browser.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
@Remote('createDirectory')
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
const request = createDirectoryRequestSchema.safeParse({ path, name })
|
||||
if (!request.success) {
|
||||
throw pickerFailureOf(
|
||||
'bad-request',
|
||||
'invalid payload for host.createDirectory',
|
||||
{ issues: request.error.issues },
|
||||
)
|
||||
}
|
||||
const capability = this.requireCapability('browse', 'createDirectory')
|
||||
try {
|
||||
return await capability.createDirectory(request.data.path, request.data.name)
|
||||
} catch (error: unknown) {
|
||||
throw browseFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */
|
||||
private requireCapability<Kind extends keyof DirectoryPickerCapabilities>(
|
||||
kind: Kind,
|
||||
method: string,
|
||||
): DirectoryPickerCapabilities[Kind] {
|
||||
const capability = this.ctx.directoryPicker.capability()
|
||||
if (capability.kind !== kind) {
|
||||
throw pickerFailureOf(
|
||||
'directory-picker-unavailable',
|
||||
`directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`,
|
||||
{ capability: capability.kind },
|
||||
)
|
||||
}
|
||||
return capability as DirectoryPickerCapabilities[Kind]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise one entry of the picking wire failure vocabulary.
|
||||
* @param code - the failure code a caller discriminates on.
|
||||
* @param message - operator-facing description.
|
||||
* @param details - the payload this code carries.
|
||||
* @returns the failure to throw across the Remote boundary.
|
||||
*/
|
||||
function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
|
||||
code: Code,
|
||||
message: string,
|
||||
details: DirectoryPickerErrorDetailsMap[Code],
|
||||
): TypertRemoteFailure {
|
||||
return new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a browse-primitive rejection: the seam's own closed codes carry the
|
||||
* path they are about, and anything else stays an infrastructure failure.
|
||||
* @param error - the primitive's rejection.
|
||||
* @returns the failure to throw across the Remote boundary.
|
||||
*/
|
||||
function browseFailure(error: unknown): TypertRemoteFailure {
|
||||
if (error instanceof DirectoryPickerError) {
|
||||
return pickerFailureOf(error.code, error.message, { path: error.path })
|
||||
}
|
||||
return pickerFailureOf('internal', errorMessage(error), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cancellable primitive's rejection. An abort is the caller's own
|
||||
* timeout or disconnect, not a backend failure, so it answers `cancelled`
|
||||
* before the business classification runs.
|
||||
* @param error - the primitive's rejection.
|
||||
* @param signal - the caller lifetime the primitive ran under.
|
||||
* @param cancelled - operator-facing text for the abort outcome.
|
||||
* @param failed - prefix for a non-seam failure, when the verb has no closed codes.
|
||||
* @returns the failure to throw across the Remote boundary.
|
||||
*/
|
||||
function cancellableFailure(
|
||||
error: unknown,
|
||||
signal: AbortSignal,
|
||||
cancelled: string,
|
||||
failed?: string,
|
||||
): TypertRemoteFailure {
|
||||
if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {})
|
||||
if (failed === undefined) return browseFailure(error)
|
||||
return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {})
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { WorkspaceCommands } from './commands.ts'
|
||||
import { DirectoryPickerController } from './directory-picker.ts'
|
||||
import { WorkspaceFeed } from './feed.ts'
|
||||
import type {
|
||||
WorkspaceArchiveSessionRequest,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { DirectoryPickerController } from './directory-picker.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -40,6 +42,11 @@ export class WorkspaceController extends TypertRemoteService {
|
||||
super(ctx, 'workspaceController', { namespace: 'workspace' })
|
||||
this.commands = new WorkspaceCommands(ctx)
|
||||
this.feed = new WorkspaceFeed(ctx)
|
||||
// This package is the Loader entry for both Remote owners it hosts: the
|
||||
// directory-picking seam is abstract and never an entry itself. The child
|
||||
// stays pending until a picking backend is composed, so a host without one
|
||||
// registers no picking namespace instead of answering an unservable verb.
|
||||
ctx.plugin(DirectoryPickerController)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */
|
||||
/**
|
||||
* Browser-safe request, result, and state-stream vocabulary for the Workspace
|
||||
* and directory-picking Remote namespaces this package owns. The picking seam
|
||||
* declares its own listing types, so they are re-exported here rather than
|
||||
* restated: a browser consumer reads the very declaration the backend answers.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type { z as zCore } from 'zod'
|
||||
|
||||
type ZodIssue = zCore.core.$ZodIssue
|
||||
|
||||
export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
|
||||
/** One durable Workspace projected for browser consumers. */
|
||||
export interface WorkspaceView {
|
||||
@@ -43,6 +52,24 @@ export type WorkspaceError = {
|
||||
}
|
||||
}[keyof WorkspaceErrorDetailsMap]
|
||||
|
||||
/** Stable directory-picking failure details returned by the picking wire verbs. */
|
||||
export interface DirectoryPickerErrorDetailsMap {
|
||||
/** The directory creation request violates its semantic input constraints. */
|
||||
'bad-request': { readonly issues: ZodIssue[] }
|
||||
/** The verb needs an interaction the composed backend does not serve. */
|
||||
'directory-picker-unavailable': { readonly capability: string }
|
||||
/** The target is not fully qualified, or the backend cannot list it. */
|
||||
'directory-unreadable': { readonly path: string }
|
||||
/** A child of that name is already there. */
|
||||
'directory-exists': { readonly path: string }
|
||||
/** The parent is not fully qualified, the name is not one segment, or creation failed. */
|
||||
'directory-create-failed': { readonly path: string }
|
||||
/** The caller's own timeout or disconnect ended the chooser or the scan. */
|
||||
cancelled: Record<never, never>
|
||||
/** A backend failure with no seam code of its own. */
|
||||
internal: Record<never, never>
|
||||
}
|
||||
|
||||
/** Existing directory requested for Workspace adoption. */
|
||||
export interface WorkspaceCreateRequest {
|
||||
readonly path: string
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { DirectoryPickerController } from '../src/directory-picker.ts'
|
||||
|
||||
const roots: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
/** A backend serving exactly the capability one case is about. */
|
||||
class StubPicker extends DirectoryPicker {
|
||||
static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
|
||||
|
||||
capability(): DirectoryPickerCapability {
|
||||
return StubPicker.capabilityStub
|
||||
}
|
||||
}
|
||||
|
||||
const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
|
||||
|
||||
const BROWSE_STUB: DirectoryPickerCapability = {
|
||||
kind: 'browse',
|
||||
list: async (path) => {
|
||||
if (path === '/denied') {
|
||||
throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
|
||||
}
|
||||
const target = path ?? '/home/user'
|
||||
return {
|
||||
path: target,
|
||||
home: '/home/user',
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }],
|
||||
entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
},
|
||||
createDirectory: async (path, name) => {
|
||||
if (name === 'taken') {
|
||||
throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
|
||||
}
|
||||
if (name === 'unwritable') throw new Error('disk detached')
|
||||
if (name === 'gone') throw 'the volume vanished'
|
||||
return `${path}/${name}`
|
||||
},
|
||||
}
|
||||
|
||||
async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) {
|
||||
StubPicker.capabilityStub = capability
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
await ctx.plugin(StubPicker).await()
|
||||
return new DirectoryPickerController(ctx)
|
||||
}
|
||||
|
||||
/** The failure payload a refused wire verb carries. */
|
||||
async function refused(call: Promise<unknown>): Promise<{ code: string; message: string; details: object }> {
|
||||
try {
|
||||
await call
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof TypertRemoteFailure)) throw error
|
||||
return { ...error.failure }
|
||||
}
|
||||
throw new Error('the call was expected to be refused')
|
||||
}
|
||||
|
||||
describe('directoryPicker pick Remote', () => {
|
||||
it('answers the selected path or the operator\'s cancellation', async () => {
|
||||
const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' })
|
||||
expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project')
|
||||
|
||||
const cancelled = await harness(NATIVE_STUB)
|
||||
expect(await cancelled.pick(new AbortController().signal)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an aborted chooser as cancelled and any other failure as internal', async () => {
|
||||
const picker = await harness({
|
||||
kind: 'native',
|
||||
pick: signal => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
}),
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const pending = refused(picker.pick(abort.signal))
|
||||
abort.abort()
|
||||
expect((await pending).code).toBe('cancelled')
|
||||
|
||||
const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
|
||||
const failure = await refused(broken.pick(new AbortController().signal))
|
||||
expect(failure.code).toBe('internal')
|
||||
expect(failure.message).toContain('no chooser installed')
|
||||
})
|
||||
|
||||
it('refuses the native verb under a browse composition', async () => {
|
||||
const picker = await harness(BROWSE_STUB)
|
||||
const failure = await refused(picker.pick(new AbortController().signal))
|
||||
expect(failure.code).toBe('directory-picker-unavailable')
|
||||
expect(failure.message).toContain('needs the native capability')
|
||||
expect(failure.details).toEqual({ capability: 'browse' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('directoryPicker browse Remotes', () => {
|
||||
it('serves listings and creation, defaulting to the home directory', async () => {
|
||||
const picker = await harness(BROWSE_STUB)
|
||||
const signal = new AbortController().signal
|
||||
expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' })
|
||||
expect(await picker.list('/home/user/projects', signal))
|
||||
.toMatchObject({ path: '/home/user/projects' })
|
||||
expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh')
|
||||
})
|
||||
|
||||
it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
|
||||
const picker = await harness(BROWSE_STUB)
|
||||
expect(await refused(picker.list('/denied', new AbortController().signal)))
|
||||
.toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } })
|
||||
expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists')
|
||||
expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal')
|
||||
|
||||
const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
|
||||
expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' })
|
||||
})
|
||||
|
||||
it('rejects invalid child names before capability dispatch', async () => {
|
||||
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
||||
const picker = await harness({
|
||||
kind: 'browse',
|
||||
list: (path, signal) => BROWSE_STUB.list(path, signal),
|
||||
createDirectory,
|
||||
})
|
||||
|
||||
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
|
||||
const failure = await refused(picker.createDirectory('/home/user', name))
|
||||
expect(failure).toMatchObject({
|
||||
code: 'bad-request',
|
||||
message: 'invalid payload for host.createDirectory',
|
||||
})
|
||||
expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
|
||||
}
|
||||
expect(createDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an aborted listing as cancelled', async () => {
|
||||
const picker = await harness({
|
||||
kind: 'browse',
|
||||
list: (_path, signal) => new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
|
||||
}),
|
||||
createDirectory: async () => '/never',
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const pending = refused(picker.list(undefined, abort.signal))
|
||||
abort.abort()
|
||||
expect((await pending).code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('refuses the browse verbs under a native composition', async () => {
|
||||
const picker = await harness()
|
||||
expect(await refused(picker.list(undefined, new AbortController().signal)))
|
||||
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
|
||||
expect(await refused(picker.createDirectory('/x', 'y')))
|
||||
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@
|
||||
{ "path": "../../client/connection/tsconfig.client.json" },
|
||||
{ "path": "../../client/store" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../host/directory-picker" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../workspace/workspace" }
|
||||
]
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
"src/invariant.ts",
|
||||
"src/types.ts",
|
||||
"src/commands.ts",
|
||||
"src/directory-picker.ts",
|
||||
"src/feed.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../host/directory-picker" },
|
||||
{ "path": "../../runtime-diagnostics/invariants" },
|
||||
{ "path": "../../storage/storage-domain" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
|
||||
@@ -303,8 +303,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
|
||||
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
|
||||
}
|
||||
const parent = resolve(path)
|
||||
// The backend owns segment validation (the wire schema also refuses these,
|
||||
// but direct service consumers must hit the same fence).
|
||||
// The backend owns segment validation; the Remote controller also refuses
|
||||
// invalid wire input, but direct service consumers must hit the same fence.
|
||||
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
||||
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless Service Definition owns the capability
|
||||
* vocabulary, while backends and the RPC consumer own observations.
|
||||
* vocabulary, while backends and the Remote controller own observations.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
Generated
+3
@@ -838,6 +838,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/store
|
||||
'@deepseek-ai/dsh-host-directory-picker':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/directory-picker
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
|
||||
Reference in New Issue
Block a user