Merge origin/master: goal domain lands beside plan
Union resolutions throughout — the fixture serves both the goal and plan projection units (catalog gains /goal beside /plan; the retired goal-fixture sample command yields to the real goal mirror), the mux baseline spec expects all four unit frames, and the tsconfig paths / Model Experience allowlist carry both domains' outlets.
This commit is contained in:
@@ -12,6 +12,7 @@ export type {
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -341,6 +341,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
// Always present (plan-mode unit composed): the {active, pending} view.
|
||||
values['plan'] = planViewOf(log)
|
||||
// Always present (GoalService unit composed): null before create / after clear.
|
||||
values['goal'] = backscanGoal(log)
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -353,6 +355,14 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
|
||||
if (type === 'user/message') {
|
||||
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
|
||||
if (source?.kind === 'goal' && source.round === 0) {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
return [{
|
||||
@@ -422,6 +432,55 @@ function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Fixture-local mirror of the goal projection value (dsh-goal's GoalProjection shape). */
|
||||
interface FxGoalProjection {
|
||||
goal: {
|
||||
id: string
|
||||
revision: number
|
||||
objective: string
|
||||
phase: 'active' | 'paused' | 'blocked' | 'complete'
|
||||
maxGoalRounds: number
|
||||
}
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One durable goal change riding a round-zero goal-sourced user message. */
|
||||
type FxGoalChange =
|
||||
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
|
||||
| {
|
||||
kind: 'goal/change'
|
||||
version: 1
|
||||
operation: 'create' | 'edit' | 'pause' | 'resume' | 'complete'
|
||||
goal: FxGoalProjection['goal']
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Current goal projection over the full log (host parallel: the GoalService
|
||||
* unit's last-wins fold of goal/change whole values; clear returns null).
|
||||
*/
|
||||
function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i] as unknown as {
|
||||
type: string
|
||||
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
|
||||
} | undefined
|
||||
if (event === undefined || event.type !== 'user/message') continue
|
||||
const source = event.data?.source
|
||||
if (source?.kind !== 'goal' || source.round !== 0) continue
|
||||
const change = source.change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (change === undefined || change.kind !== 'goal/change') continue
|
||||
if (change.operation === 'clear') return null
|
||||
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
@@ -613,6 +672,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
|
||||
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
|
||||
const ref = change.operation === 'clear' ? change.cleared : change.goal
|
||||
const payload = change.operation === 'clear'
|
||||
? { cleared: change.cleared, clearedAt: change.clearedAt }
|
||||
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
append(id, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(
|
||||
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
|
||||
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
|
||||
),
|
||||
})
|
||||
return backscanGoal(logOf(id)) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
@@ -981,7 +1081,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
|
||||
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
|
||||
],
|
||||
})
|
||||
@@ -998,6 +1098,29 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||
const name = match?.[1]
|
||||
const args = match?.[2] ?? ''
|
||||
if (name === 'goal') {
|
||||
// Host parallel: /goal with an objective creates (or reports) the
|
||||
// current goal; the command lifecycle pair brackets the mutation.
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
const objective = args.trim()
|
||||
const current = backscanGoal(logOf(id))
|
||||
let text: string
|
||||
if (objective === '') {
|
||||
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
|
||||
} else if (current !== null && current.goal.phase !== 'complete') {
|
||||
text = `A goal already exists (${current.goal.objective}). Clear it first.`
|
||||
} else {
|
||||
const created = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
text = `Goal created: ${created.goal.objective}`
|
||||
}
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
}
|
||||
// Host parallel: /plan on an idle fixture session commits plan/mode
|
||||
// immediately (the boundary flush covers only a running turn), so the
|
||||
// outcome copy matches the immediate branch of the host handler.
|
||||
@@ -1005,7 +1128,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const outcomes: Record<string, string> = {
|
||||
compact: 'fixture:已压缩(假动作)',
|
||||
echo: args.trim(),
|
||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
||||
plan: args.trim() === 'off'
|
||||
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
|
||||
: (running
|
||||
@@ -1037,6 +1159,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
const conn = new FxInbox<MuxFrame>()
|
||||
@@ -1167,6 +1345,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
case 'goal.resume': return this.api.goals.resume(request)
|
||||
case 'goal.complete': return this.api.goals.complete(request)
|
||||
case 'goal.clear': return this.api.goals.clear(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
|
||||
@@ -127,6 +127,15 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'plan'])
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
@@ -64,11 +64,11 @@ describe('createFixtureApi commands/skills', () => {
|
||||
|
||||
it('addresses execute to the session; an unknown session errs', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('createFixtureApi', () => {
|
||||
// and plan-mode are mounted): the empty-log values.
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [], hasMore: false,
|
||||
projections: { asOfSeq: -1, values: { todos: null, plan: { active: false, pending: false } } },
|
||||
projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 6) abort.abort()
|
||||
if (envelopes.length >= 7) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -216,14 +216,15 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + plan units).
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + plan + goal units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId)
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -701,6 +702,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
|
||||
// Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear;
|
||||
// every mutation acknowledges with the NEW CAS ref (state rides the projection frames).
|
||||
const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' })
|
||||
if (!goalCreated.result.ok) throw new Error('goal create failed')
|
||||
let ref = goalCreated.result.value.ref
|
||||
expect(ref.revision).toBe(1)
|
||||
const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' })
|
||||
if (!edited.result.ok) throw new Error('goal edit failed')
|
||||
ref = edited.result.value.ref
|
||||
const paused = await client.goals.pause({ sessionId: id, ref })
|
||||
if (!paused.result.ok) throw new Error('goal pause failed')
|
||||
ref = paused.result.value.ref
|
||||
const resumed = await client.goals.resume({ sessionId: id, ref })
|
||||
if (!resumed.result.ok) throw new Error('goal resume failed')
|
||||
ref = resumed.result.value.ref
|
||||
// A stale ref loses the CAS check.
|
||||
expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false)
|
||||
const completed = await client.goals.complete({ sessionId: id, ref })
|
||||
if (!completed.result.ok) throw new Error('goal complete failed')
|
||||
ref = completed.result.value.ref
|
||||
// complete → complete is an invalid transition.
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
|
||||
Reference in New Issue
Block a user