mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 20:24:32 +00:00
refactor(core): bind standalone skill activation to Session (#46077)
Move standalone skill activation into ID-bound Session handles and delegate from the public service. Preserve current-placement lookup, raw skill content, ambient publication context, validation order, and host-scoped detached resume behavior. Cover ownership and lifecycle contracts with focused regressions.
This commit is contained in:
parent
b1e3a7b222
commit
4a977b2b31
4 changed files with 220 additions and 40 deletions
|
|
@ -1,7 +1,7 @@
|
|||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
|
|
@ -50,8 +50,6 @@ import { Session } from "./session/session.js"
|
|||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Skill } from "./skill.js"
|
||||
import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
|
@ -202,12 +200,9 @@ export interface Interface {
|
|||
readonly shell: (
|
||||
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["shell"]>
|
||||
readonly skill: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: Skill.ID
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||
readonly skill: (
|
||||
input: Parameters<Session.Handle["skill"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["skill"]>
|
||||
readonly compact: (
|
||||
input: CompactInput,
|
||||
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
|
||||
|
|
@ -247,7 +242,6 @@ const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const sessions = yield* Session.make((ref) => locations.get(ref))
|
||||
const admission = yield* SessionInbox.Service
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
|
|
@ -435,26 +429,7 @@ const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
shell: (input) => sessions.forSession(input.sessionID).shell(input),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
},
|
||||
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
|
||||
)
|
||||
if (input.resume !== false)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
skill: (input) => sessions.forSession(input.sessionID).skill(input),
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Location } from "../location.js"
|
|||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { Shell } from "../shell.js"
|
||||
import { ShellResult } from "../shell/result.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import {
|
||||
BusyError,
|
||||
CompactionConflictError,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
MessageToolIncompleteError,
|
||||
NotFoundError,
|
||||
PromptConflictError,
|
||||
SkillNotFoundError,
|
||||
SyntheticConflictError,
|
||||
} from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
|
|
@ -30,7 +32,12 @@ import { SessionRevert } from "./revert.js"
|
|||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export type Services = PluginSupervisor.Service | SessionPrompt.Service | SessionRevert.Service | Shell.Service
|
||||
export type Services =
|
||||
| PluginSupervisor.Service
|
||||
| SessionPrompt.Service
|
||||
| SessionRevert.Service
|
||||
| Shell.Service
|
||||
| Skill.Service
|
||||
|
||||
type PromptRequest = SessionPrompt.Input & {
|
||||
id?: SessionMessage.ID
|
||||
|
|
@ -238,6 +245,29 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
|||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
yield* Fiber.join(running)
|
||||
})
|
||||
const skill = Effect.fn("Session.skill")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
{
|
||||
sessionID,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
},
|
||||
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
|
||||
)
|
||||
if (input.resume !== false)
|
||||
yield* execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
})
|
||||
const compact = Effect.fn("Session.compact")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
|
||||
|
|
@ -346,6 +376,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
|||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
|
|
@ -368,6 +399,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
|||
const prompt = operations.prompt.bind(undefined, sessionID)
|
||||
const synthetic = operations.synthetic.bind(undefined, sessionID)
|
||||
const shell = operations.shell.bind(undefined, sessionID)
|
||||
const skill = operations.skill.bind(undefined, sessionID)
|
||||
const compact = operations.compact.bind(undefined, sessionID)
|
||||
const wait = operations.wait.bind(undefined, sessionID)
|
||||
const resume = operations.resume.bind(undefined, sessionID)
|
||||
|
|
@ -393,6 +425,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
|||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { and, eq } from "drizzle-orm"
|
|||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
|
|
@ -16,6 +15,7 @@ import { Bus } from "../src/bus.js"
|
|||
import { Database } from "../src/database/database.js"
|
||||
import { EventTable } from "../src/event/sql.js"
|
||||
import { Image } from "../src/image.js"
|
||||
import { Location } from "../src/location.js"
|
||||
import { PluginHooks } from "../src/plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
|
||||
import { ProjectTable } from "../src/project/sql.js"
|
||||
|
|
@ -37,6 +37,7 @@ import { Shell } from "../src/shell.js"
|
|||
import { Skill } from "../src/skill.js"
|
||||
import { Snapshot } from "../src/snapshot.js"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
|
|
@ -58,10 +59,18 @@ const it = testEffect(
|
|||
const sessionID = SessionSchema.ID.make("ses_owned")
|
||||
const otherID = SessionSchema.ID.make("ses_owned_other")
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const skillInfo = Skill.Info.make({
|
||||
id: Skill.ID.make("guide"),
|
||||
name: Skill.Name.make("Guide"),
|
||||
description: "Session guidance",
|
||||
location: AbsolutePath.make("/skills/guide/SKILL.md"),
|
||||
content: " Raw guidance\n",
|
||||
})
|
||||
|
||||
const setup = Effect.fnUntraced(function* (options?: {
|
||||
execution?: SessionExecution.Interface
|
||||
shell?: Layer.Layer<Shell.Service>
|
||||
skills?: (ref: Location.Ref) => Layer.Layer<Skill.Service>
|
||||
snapshot?: (ref: Location.Ref) => Layer.Layer<Snapshot.Service>
|
||||
}) {
|
||||
const database = yield* Database.Service
|
||||
|
|
@ -86,11 +95,15 @@ const setup = Effect.fnUntraced(function* (options?: {
|
|||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
|
||||
const locations: Location.Ref[] = []
|
||||
const flushes: Location.Ref[] = []
|
||||
const resumes: SessionSchema.ID[] = []
|
||||
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
|
||||
const execution = SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set<SessionSchema.ID>()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
resume: (id) =>
|
||||
Effect.sync(() => {
|
||||
resumes.push(id)
|
||||
}),
|
||||
awaitIdle: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
wake: (id) =>
|
||||
|
|
@ -113,7 +126,6 @@ const setup = Effect.fnUntraced(function* (options?: {
|
|||
const services = Layer.mergeAll(
|
||||
Layer.succeed(PluginHooks.Service, hooks),
|
||||
Layer.mock(Image.Service, {}),
|
||||
Layer.mock(Skill.Service, {}),
|
||||
options?.shell ?? Layer.mock(Shell.Service, {}),
|
||||
)
|
||||
const servicesFor = (ref: Location.Ref): Layer.Layer<Session.Services> => {
|
||||
|
|
@ -122,6 +134,11 @@ const setup = Effect.fnUntraced(function* (options?: {
|
|||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
services,
|
||||
Layer.succeed(Location.Service, location(ref)),
|
||||
options?.skills?.(ref) ??
|
||||
Layer.mock(Skill.Service, {
|
||||
get: (id) => Effect.succeed(id === skillInfo.id ? skillInfo : undefined),
|
||||
}),
|
||||
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
|
|
@ -146,7 +163,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
|||
>(),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, flushes, wakes, db: database.db, bus, store }
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
})
|
||||
|
||||
describe("Session-owned handles", () => {
|
||||
|
|
@ -339,6 +356,144 @@ describe("Session-owned handles", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("activates skills through detached handles using fresh placement and ambient publication context", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({
|
||||
skills: (ref) =>
|
||||
Layer.mock(Skill.Service, { get: () => Effect.succeed({ ...skillInfo, content: ref.directory }) }),
|
||||
})
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const { skill } = handle
|
||||
const events: Event.Payload[] = []
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
const initial = SessionMessage.ID.make("msg_owned_skill_initial")
|
||||
yield* skill({ id: initial, skill: skillInfo.id, resume: false }).pipe(
|
||||
Effect.satisfiesServicesType<never>(),
|
||||
Effect.setContext(Context.empty()),
|
||||
)
|
||||
const moved = SessionMessage.ID.make("msg_owned_skill_moved")
|
||||
const activation = skill({ id: moved, skill: skillInfo.id, resume: false })
|
||||
const destination = Location.Ref.make({ directory: AbsolutePath.make("/project/moved") })
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
location: destination,
|
||||
projectID: Project.ID.global,
|
||||
subpath: RelativePath.make("moved"),
|
||||
})
|
||||
|
||||
yield* activation.pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
|
||||
yield* skill({ skill: skillInfo.id, resume: false }).pipe(
|
||||
Effect.provideService(Location.Service, location(source)),
|
||||
)
|
||||
|
||||
expect(fixture.locations).toEqual([source, destination, destination])
|
||||
expect(yield* handle.message(initial)).toMatchObject({ type: "skill", text: source.directory })
|
||||
expect(yield* handle.message(moved)).toMatchObject({ type: "skill", text: destination.directory })
|
||||
expect(
|
||||
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
|
||||
).toEqual([undefined, undefined, source])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("checks Session existence before skill lookup and leaves missing activations untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const events: Event.Payload[] = []
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
const missingID = SessionSchema.ID.make("ses_missing_skill")
|
||||
expect(
|
||||
yield* fixture.sessions.forSession(missingID).skill({ skill: skillInfo.id }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.NotFoundError", sessionID: missingID })
|
||||
expect(fixture.locations).toEqual([])
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const before = yield* handle.get()
|
||||
const missing = Skill.ID.make("missing")
|
||||
|
||||
expect(yield* handle.skill({ skill: missing }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.SkillNotFoundError",
|
||||
skill: missing,
|
||||
})
|
||||
|
||||
expect(fixture.locations).toEqual([source])
|
||||
expect(events).toEqual([])
|
||||
expect(yield* handle.get()).toEqual(before)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
expect(yield* fixture.store.context(sessionID)).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("publishes skills before detached resumes and owns those resumes in the host scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const host = yield* Scope.fork(scope, "sequential")
|
||||
const calls: string[] = []
|
||||
const stopped: SessionSchema.ID[] = []
|
||||
const execution = yield* SessionExecution.Service.pipe(Effect.provide(SessionExecution.noopLayer))
|
||||
const fixture = yield* setup({
|
||||
execution: {
|
||||
...execution,
|
||||
resume: (id) =>
|
||||
Effect.gen(function* () {
|
||||
calls.push(`resume:${id}`)
|
||||
yield* Effect.never
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
stopped.push(id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
wake: () =>
|
||||
Effect.sync(() => {
|
||||
calls.push("wake")
|
||||
}),
|
||||
},
|
||||
}).pipe(Scope.provide(host))
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === SessionEvent.Skill.Activated.type) calls.push(`published:${event.id}`)
|
||||
}),
|
||||
)
|
||||
const { skill } = fixture.sessions.forSession(sessionID)
|
||||
|
||||
yield* skill({ id: SessionMessage.ID.make("msg_skill_no_resume"), skill: skillInfo.id, resume: false })
|
||||
expect(calls).toEqual(["published:evt_skill_no_resume"])
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ id: SessionMessage.ID.make("msg_skill_default_resume"), skill: skillInfo.id },
|
||||
{ id: SessionMessage.ID.make("msg_skill_explicit_resume"), skill: skillInfo.id, resume: true },
|
||||
],
|
||||
(input) => skill(input).pipe(Effect.scoped, Effect.forkScoped, Effect.flatMap(Fiber.join)),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
"published:evt_skill_no_resume",
|
||||
"published:evt_skill_default_resume",
|
||||
`resume:${sessionID}`,
|
||||
"published:evt_skill_explicit_resume",
|
||||
`resume:${sessionID}`,
|
||||
])
|
||||
expect(stopped).toEqual([])
|
||||
yield* Scope.close(host, Exit.void)
|
||||
expect(stopped).toEqual([sessionID, sessionID])
|
||||
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps prompt wakes independent of shell work across handles", () =>
|
||||
Effect.gen(function* () {
|
||||
const blocked = yield* Deferred.make<void>()
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ const info = Skill.Info.make({
|
|||
name: Skill.Name.make("Effect"),
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
|
||||
content: "Use Effect",
|
||||
content: " Use Effect\n",
|
||||
})
|
||||
const locations = makeGlobalNode({
|
||||
service: LocationServiceMap.Service,
|
||||
|
|
@ -146,17 +148,32 @@ describe("Session.skill", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("projects the caller-supplied message ID", () =>
|
||||
it.effect("publishes raw standalone content under the caller-supplied ID without inbox admission", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
const id = SessionMessage.ID.make("msg_caller_skill")
|
||||
const events: Event.Payload[] = []
|
||||
yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
|
||||
)
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "evt_caller_skill",
|
||||
type: SessionEvent.Skill.Activated.type,
|
||||
data: { sessionID: session.id, id: info.id, name: info.name, text: info.content },
|
||||
}),
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
|
||||
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: info.content }),
|
||||
])
|
||||
expect(yield* sessions.inbox(session.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue