This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new e7599f8 feat(agents): list the installation itself as instance 1
e7599f8 is described below
commit e7599f8b7455b0f8e5dd149bc1bb3efe7cba4dd9
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 9 20:45:05 2026 +0800
feat(agents): list the installation itself as instance 1
---
agent/instance.go | 35 +++++++++
controllers/agent_instance.go | 136 +++++++++++++++++++++++++++++++---
web/src/components/AgentInstances.tsx | 49 ++++++++----
web/src/lib/agents.ts | 9 ++-
web/src/locales/en/data.json | 4 +-
web/src/locales/zh/data.json | 4 +-
web/src/types.ts | 3 +
7 files changed, 211 insertions(+), 29 deletions(-)
diff --git a/agent/instance.go b/agent/instance.go
index 8cbba0a..9ca2788 100644
--- a/agent/instance.go
+++ b/agent/instance.go
@@ -90,6 +90,41 @@ func InstanceDir(agentId string, name string) (string,
error) {
return filepath.Join(home, instancesDir, agentId, name), nil
}
+// DefaultInstanceDir is the state directory the first instance of an agent
runs
+// on: the one it uses when it is started with no instance of its own. An agent
+// isolated by an environment variable keeps it under the home directory, the
+// way its own default does; one isolated by a flag is a desktop app, whose
+// default is wherever it puts its user data, known only for the apps Gateway
+// reads an account from. Empty where Gateway does not know it.
+func DefaultInstanceDir(agentId string, owner string) string {
+ rules := isolationOf(agentId)
+ if !rules.supported() {
+ return ""
+ }
+ home, err := agenthome.Resolve(owner)
+ if err != nil {
+ return ""
+ }
+ if rules.env != "" {
+ if dir := stateDirOf(agentId); dir != "" {
+ return filepath.Join(home, "."+dir)
+ }
+ return ""
+ }
+ return accountStateDir(accountKind(agentId), home)
+}
+
+// stateDirOf is the directory, named without its leading dot, that an agent
+// keeps its state in under a home.
+func stateDirOf(agentId string) string {
+ for i := range fingerprints {
+ if fingerprints[i].ID == agentId {
+ return fingerprints[i].StateDir
+ }
+ }
+ return ""
+}
+
// CheckInstanceName keeps an instance name to what is safe as one path segment
// and readable as a label.
func CheckInstanceName(name string) error {
diff --git a/controllers/agent_instance.go b/controllers/agent_instance.go
index b0ad090..09f4edb 100644
--- a/controllers/agent_instance.go
+++ b/controllers/agent_instance.go
@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"strconv"
+ "strings"
"time"
"github.com/apache/casbin-gateway/agent"
@@ -33,6 +34,11 @@ import (
// keeps asking cannot fill the disk with empty profiles.
const maxInstances = 50
+// defaultInstanceName is what the installation's own copy is listed as. It
runs
+// on the state directory the agent uses by itself, so no row is stored for it
+// and the names Gateway hands out start at the second.
+const defaultInstanceName = "1"
+
// linkCaptureTtl is how long a copy waiting to be signed in holds the URL
scheme
// of its agent: long enough for a sign-in in a browser, short enough that a
link
// arriving later still opens whichever copy the agent registered itself for.
@@ -46,6 +52,9 @@ type agentInstanceView struct {
// Desktop tells the UI what a start would open: the app itself, or a
console
// window for a CLI.
Desktop bool `json:"desktop"`
+ // Default marks the installation's own copy, which is listed beside the
+ // extra ones but is not stored, renamed or removed.
+ Default bool `json:"default"`
// CanCapture is whether Gateway can route this agent's own links here
at
// all, and Capturing whether the next one will open this copy.
CanCapture bool `json:"canCapture"`
@@ -61,7 +70,8 @@ func (c *ApiController) GetAgentInstances() {
return
}
- instances, err := object.GetAgentInstances(c.Input().Get("agent"))
+ agentId := c.Input().Get("agent")
+ instances, err := object.GetAgentInstances(agentId)
if err != nil {
c.ResponseError(err.Error())
return
@@ -70,7 +80,12 @@ func (c *ApiController) GetAgentInstances() {
agentprocess.Refresh()
}
- result := make([]*agentInstanceView, 0, len(instances))
+ // The installation itself is the first copy, so it is listed first,
ahead of
+ // the ones stored beside it.
+ result := make([]*agentInstanceView, 0, len(instances)+1)
+ for _, installation := range instancedInstallations(agentId) {
+ result = append(result,
instanceView(defaultInstance(installation)))
+ }
for _, instance := range instances {
result = append(result, instanceView(instance))
}
@@ -120,6 +135,10 @@ func (c *ApiController) AddAgentInstance() {
c.ResponseError(err.Error())
return
}
+ if form.Instance == defaultInstanceName {
+ c.ResponseError(fmt.Sprintf("%s is the installation's own copy
and is listed on its own", defaultInstanceName))
+ return
+ }
dataDir, err := agent.InstanceDir(installation.AgentId, form.Instance)
if err != nil {
@@ -159,6 +178,10 @@ func (c *ApiController) UpdateAgentInstance() {
if !ok {
return
}
+ if isDefaultInstance(instance) {
+ c.ResponseError("the first instance is the installation itself
and is named after it")
+ return
+ }
var form struct {
DisplayName string `json:"displayName"`
}
@@ -207,6 +230,10 @@ func (c *ApiController) DeleteAgentInstance() {
if !ok {
return
}
+ if isDefaultInstance(instance) {
+ c.ResponseError("the first instance is the installation itself
and cannot be removed")
+ return
+ }
if err := object.DeleteAgentInstance(instance.Name); err != nil {
c.ResponseError(err.Error())
return
@@ -230,9 +257,12 @@ func (c *ApiController) StartAgentInstance() {
c.ResponseError(err.Error())
return
}
- if err := agent.PrepareInstance(installation, instance.DataDir); err !=
nil {
- c.ResponseError(err.Error())
- return
+ // The first copy runs on the state the agent laid out itself.
+ if !isDefaultInstance(instance) {
+ if err := agent.PrepareInstance(installation,
instance.DataDir); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
}
target, err := instanceTarget(installation, instance)
@@ -249,7 +279,8 @@ func (c *ApiController) StartAgentInstance() {
// directory. A copy with no account in it is one that is about to sign
in.
// The start is not worth failing over it: the copy runs either way,
and the
// pages show whether the link was captured.
- if agentlink.Supported() && agent.AccountOfInstance(instance.AgentId,
instance.DataDir) == nil {
+ if !isDefaultInstance(instance) && agentlink.Supported() &&
+ agent.AccountOfInstance(instance.AgentId, instance.DataDir) ==
nil {
if err := captureLink(instance, target); err != nil {
beego.Error("the sign-in link of", instance.Name,
"cannot be routed to it:", err)
}
@@ -270,6 +301,10 @@ func (c *ApiController) CaptureAgentInstanceLink() {
if !ok {
return
}
+ if isDefaultInstance(instance) {
+ c.ResponseError("the agent's own links already open the first
instance")
+ return
+ }
var form struct {
Capture bool `json:"capture"`
}
@@ -369,20 +404,90 @@ func (c *ApiController) readAgentInstance()
(*object.AgentInstance, bool) {
return nil, false
}
if instance == nil {
- c.ResponseError("no agent instance is stored under this name")
- return nil, false
+ // The first copy is stored nowhere, so it is rebuilt from the
+ // installation it runs.
+ if instance = defaultInstanceNamed(form.Name); instance == nil {
+ c.ResponseError("no agent instance is stored under this
name")
+ return nil, false
+ }
}
return instance, true
}
+// isDefaultInstance reports whether one instance is the installation's own
copy
+// rather than a stored one. The name is Gateway's to hand out and starts at
the
+// second, so no stored instance answers to it.
+func isDefaultInstance(instance *object.AgentInstance) bool {
+ return instance != nil && instance.Instance == defaultInstanceName
+}
+
+// defaultInstance is the installation's own copy as the lists show it: the
+// state directory the agent uses when nothing hands it another one, under the
+// name every call addresses it by.
+func defaultInstance(installation agent.Installation) *object.AgentInstance {
+ return &object.AgentInstance{
+ Owner: object.AgentOwner,
+ Name: object.AgentInstanceName(installation.AgentId,
defaultInstanceName),
+ AgentId: installation.AgentId,
+ Instance: defaultInstanceName,
+ DataDir: agent.DefaultInstanceDir(installation.AgentId,
installation.Owner),
+ Path: installation.Path,
+ HostUser: installation.Owner,
+ }
+}
+
+// defaultInstanceNamed resolves the name of a first copy against the
+// installations found here, nil for a name that is not one.
+func defaultInstanceNamed(name string) *object.AgentInstance {
+ agentId, instance, found := strings.Cut(name, "/")
+ if !found || instance != defaultInstanceName {
+ return nil
+ }
+ installations := instancedInstallations(agentId)
+ if len(installations) == 0 {
+ return nil
+ }
+ return defaultInstance(installations[0])
+}
+
+// instancedInstallations are the installations whose own copy is listed as the
+// first instance: one per agent Gateway can run more than one copy of, since
+// the extra copies are listed per agent too.
+func instancedInstallations(agentId string) []agent.Installation {
+ installations, err := agent.Scan(false)
+ if err != nil {
+ return nil
+ }
+
+ listed := map[string]bool{}
+ result := []agent.Installation{}
+ for _, installation := range installations {
+ if agentId != "" && installation.AgentId != agentId {
+ continue
+ }
+ if !agent.SupportsInstances(installation.AgentId) ||
listed[installation.AgentId] {
+ continue
+ }
+ listed[installation.AgentId] = true
+ result = append(result, installation)
+ }
+ return result
+}
+
func instanceView(instance *object.AgentInstance) *agentInstanceView {
+ isDefault := isDefaultInstance(instance)
view := &agentInstanceView{
AgentInstance: instance,
- Account: agent.AccountOfInstance(instance.AgentId,
instance.DataDir),
+ Default: isDefault,
Status: agentprocess.Status{Pids: []int{}},
}
+ if !isDefault {
+ view.Account = agent.AccountOfInstance(instance.AgentId,
instance.DataDir)
+ }
- if scheme := agent.LinkSchemeOf(instance.AgentId); scheme != "" &&
agentlink.Supported() {
+ // The agent's own links open the first copy already, so there is
nothing to
+ // route for it.
+ if scheme := agent.LinkSchemeOf(instance.AgentId); scheme != "" &&
agentlink.Supported() && !isDefault {
view.CanCapture = true
claim, pending := agentlink.Pending(scheme)
view.Capturing = pending && claim.Instance == instance.Name
@@ -390,6 +495,11 @@ func instanceView(instance *object.AgentInstance)
*agentInstanceView {
installation, err := findInstallation(instance.AgentId, instance.Path,
instance.HostUser)
if err == nil {
+ // The first copy is signed in wherever the installation is,
which the
+ // scan has already read.
+ if isDefault {
+ view.Account = installation.Account
+ }
var target agentprocess.Target
if target, err = instanceTarget(installation, instance); err ==
nil {
view.Desktop = target.Desktop
@@ -402,6 +512,12 @@ func instanceView(instance *object.AgentInstance)
*agentInstanceView {
}
func instanceTarget(installation agent.Installation, instance
*object.AgentInstance) (agentprocess.Target, error) {
+ // The first copy is the installation run as it stands, which is also
what
+ // counts its processes apart from the other copies'.
+ if isDefaultInstance(instance) {
+ return processTarget(installation), nil
+ }
+
launch, err := agent.InstanceLaunchOf(installation, instance.DataDir)
if err != nil {
return agentprocess.Target{}, err
diff --git a/web/src/components/AgentInstances.tsx
b/web/src/components/AgentInstances.tsx
index 87ea9a2..17b2d35 100644
--- a/web/src/components/AgentInstances.tsx
+++ b/web/src/components/AgentInstances.tsx
@@ -81,6 +81,21 @@ function AddInstanceButton({
);
}
+/**
+ * The name of the first copy, which is the installation itself: it is named
+ * after the installation wherever it is listed, so there is nothing to edit.
+ */
+function DefaultInstanceName({instance}: {instance: AgentInstance}) {
+ return (
+ <SimpleTooltip title={i18next.t("agent:Default instance hint")}>
+ <span className="inline-flex items-center gap-1.5 px-1 font-medium">
+ {instance.instance}
+ <Badge variant="muted">{i18next.t("agent:Default instance")}</Badge>
+ </span>
+ </SimpleTooltip>
+ );
+}
+
/**
* The name of one instance, editable where it is listed. A copy is added in
one
* click and numbered by the server, so this is where it is given a name worth
@@ -209,7 +224,12 @@ export function AgentInstances({agent, enabled = true}:
{agent: Agent; enabled?:
title: i18next.t("general:Name"),
key: "instance",
dataIndex: "instance",
- render: (_value, record) => <InstanceName instance={record}
onRename={controls.rename} />,
+ render: (_value, record) =>
+ record.default ? (
+ <DefaultInstanceName instance={record} />
+ ) : (
+ <InstanceName instance={record} onRename={controls.rename} />
+ ),
},
{
title: i18next.t("agent:Account"),
@@ -254,7 +274,8 @@ export function AgentInstances({agent, enabled = true}:
{agent: Agent; enabled?:
key: "dataDir",
dataIndex: "dataDir",
ellipsis: true,
- render: (value: string) => <CodeText copyable>{value}</CodeText>,
+ render: (value: string) =>
+ value ? <CodeText copyable>{value}</CodeText> : <span
className="text-muted-foreground">-</span>,
},
{
title: i18next.t("general:Action"),
@@ -271,17 +292,19 @@ export function AgentInstances({agent, enabled = true}:
{agent: Agent; enabled?:
busy={controls.busyName === record.name}
onToggle={controls.toggleCapture}
/>
- <ConfirmDialog
- title={`${i18next.t("agent:Remove instance")}:
${instanceLabel(record)}?`}
- description={i18next.t("agent:Remove instance hint")}
- confirmText={i18next.t("agent:Remove instance")}
- variant="destructive"
- onConfirm={() => controls.remove(record)}
- >
- <Button size="icon" variant="ghost" className="size-6">
- <Trash2 />
- </Button>
- </ConfirmDialog>
+ {record.default ? null : (
+ <ConfirmDialog
+ title={`${i18next.t("agent:Remove instance")}:
${instanceLabel(record)}?`}
+ description={i18next.t("agent:Remove instance hint")}
+ confirmText={i18next.t("agent:Remove instance")}
+ variant="destructive"
+ onConfirm={() => controls.remove(record)}
+ >
+ <Button size="icon" variant="ghost" className="size-6">
+ <Trash2 />
+ </Button>
+ </ConfirmDialog>
+ )}
</div>
),
},
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index a723901..7066593 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -846,9 +846,10 @@ export function getOutcomeVariant(outcome: string |
undefined): BadgeVariant {
}
/**
- * useAgentInstances owns the extra copies of one agent: the ones stored, what
- * each is signed in to, and whether each is running. Each has a state
directory
- * of its own, so they start, stop and sign in independently of one another.
+ * useAgentInstances owns the copies of one agent: the installation's own, the
+ * ones stored beside it, what each is signed in to, and whether each is
+ * running. Each has a state directory of its own, so they start, stop and sign
+ * in independently of one another.
*/
export function useAgentInstances(agentId = "", enabled = true) {
const [instances, setInstances] = React.useState<AgentInstance[]>([]);
@@ -1016,7 +1017,7 @@ export function useAgentInstances(agentId = "", enabled =
true) {
/** What one hook call returns, as the cards and the tables take it. */
export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
-/** The instances belonging to one installation, out of a listing of them all.
*/
+/** The copies of one installation, the first included, out of a listing of
them all. */
export function instancesOf(instances: AgentInstance[], agent: Agent) {
return instances.filter(instance => instance.agentId === agent.agentId);
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ed64372..ed8af3b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -95,6 +95,8 @@
"Copy path": "Copy path",
"Create a directory": "Create a directory",
"Create or overwrite a file": "Create or overwrite a file",
+ "Default instance": "Default instance",
+ "Default instance hint": "The agent's own copy, on the state directory it
uses when nothing hands it another one. Gateway lists it beside the extra
copies, but neither names nor removes it.",
"Delete a file": "Delete a file",
"Detail": "Detail",
"Details": "Details",
@@ -167,7 +169,7 @@
"Instance added": "Instance added",
"Instance removed": "Instance removed",
"Instances": "Instances",
- "Instances hint": "Extra copies of this agent, each with a state directory
of its own, so each is signed in to its own account and they run at the same
time.",
+ "Instances hint": "Every copy of this agent: the installation itself, and
the extra ones added here. Each has a state directory of its own, so each is
signed in to its own account and they run at the same time.",
"Interactive hint": "This opens a window on the machine and waits for an
answer there.",
"Keep a task list": "Keep a task list",
"Kimi Code config hint": "Kimi Code keeps the endpoint in
~/.kimi-code/config.toml: a [providers.casbin-gateway] table with type
\"openai\", base_url and api_key, and one [models.*] alias per model, picked by
default_model at the root of the file. It reads no credential from the
environment, and KIMI_CODE_HOME moves the whole directory.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 674936b..2b4f21f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -95,6 +95,8 @@
"Copy path": "复制路径",
"Create a directory": "新建目录",
"Create or overwrite a file": "新建或覆盖文件",
+ "Default instance": "默认实例",
+ "Default instance hint":
"Agent自身的副本,运行在它默认使用的状态目录上。Gateway只把它和其他副本一起列出,不会重命名或移除它。",
"Delete a file": "删除文件",
"Detail": "详情",
"Details": "详情",
@@ -167,7 +169,7 @@
"Instance added": "已添加实例",
"Instance removed": "已移除实例",
"Instances": "实例",
- "Instances hint": "该Agent的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
+ "Instances hint":
"该Agent的全部副本:安装本身,以及在这里添加的其他副本。每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
"Interactive hint": "这会在本机弹出窗口,需要在本机上确认。",
"Keep a task list": "维护任务清单",
"Kimi Code config hint": "Kimi
Code的端点写在~/.kimi-code/config.toml里:一个[providers.casbin-gateway]表,含type
\"openai\"、base_url和api_key,每个模型再写一个[models.*]别名,由文件根部的default_model选中。它不从环境变量读取任何凭据,KIMI_CODE_HOME会把整个目录挪到别处。",
diff --git a/web/src/types.ts b/web/src/types.ts
index adc40e6..cdf4441 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -332,6 +332,9 @@ export interface AgentInstance {
/** The account this copy is signed in to, absent until someone signs in. */
account?: AgentAccount;
desktop: boolean;
+ /** Whether this is the installation's own copy, the first instance, which is
+ * listed beside the extra ones but is not renamed or removed. */
+ default?: boolean;
running: boolean;
pids: number[];
canStart: boolean;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]