mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-02 07:34:38 +08:00
feat: apply resource policy on user create and require cron job names
Admins can set storage-root and token quota when creating a user, and cronjob_create now requires a display name with prompt-prefix fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
jubaoliang
co-authored by
Cursor
parent
58b31489b2
commit
fcf23bdca4
@@ -4605,9 +4605,9 @@
|
||||
"createSectionAccount": "Account",
|
||||
"createSectionAccess": "Role & permissions",
|
||||
"createSectionPolicy": "Resource usage policy",
|
||||
"policyWorkspaceRoot": "Allowed workspace root",
|
||||
"policyWorkspaceRoot": "Allowed storage root",
|
||||
"policyWorkspaceRootHint": "Restricts roots for “{{localShell}}” and “{{filesystem}}” when creating experts. Off means no extra limit.",
|
||||
"policyWorkspaceRootRequired": "Choose an allowed root directory",
|
||||
"policyWorkspaceRootRequired": "Choose an allowed storage root",
|
||||
"policyTokenQuota": "Token usage limit",
|
||||
"policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit.",
|
||||
"policyTokenQuotaRequired": "Enter a token quota",
|
||||
|
||||
@@ -4744,9 +4744,9 @@
|
||||
"createSectionAccount": "账号信息",
|
||||
"createSectionAccess": "角色与权限",
|
||||
"createSectionPolicy": "资源使用策略",
|
||||
"policyWorkspaceRoot": "允许的工作区根目录",
|
||||
"policyWorkspaceRoot": "允许的存储根目录",
|
||||
"policyWorkspaceRootHint": "限制该用户创建专家时「{{localShell}}」/「{{filesystem}}」的根目录。关闭表示不额外限制。",
|
||||
"policyWorkspaceRootRequired": "请选择允许的根目录",
|
||||
"policyWorkspaceRootRequired": "请选择允许的存储根目录",
|
||||
"policyTokenQuota": "Token 使用上限",
|
||||
"policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。",
|
||||
"policyTokenQuotaRequired": "请输入 token 上限",
|
||||
|
||||
@@ -111,7 +111,14 @@ function permFullLabel(item: PermissionCatalogItem): string {
|
||||
return item.label;
|
||||
}
|
||||
|
||||
interface CreateValues {
|
||||
interface PolicyFormValues {
|
||||
limit_workspace_root?: boolean;
|
||||
workspace_root_dir?: string;
|
||||
limit_token_quota?: boolean;
|
||||
token_quota?: number | null;
|
||||
}
|
||||
|
||||
interface CreateValues extends PolicyFormValues {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
@@ -121,15 +128,11 @@ interface CreateValues {
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
interface EditValues extends PolicyFormValues {
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
role: "admin" | "user";
|
||||
permissions?: string[];
|
||||
limit_workspace_root?: boolean;
|
||||
workspace_root_dir?: string;
|
||||
limit_token_quota?: boolean;
|
||||
token_quota?: number | null;
|
||||
}
|
||||
|
||||
interface ResetValues {
|
||||
@@ -197,6 +200,92 @@ const FIELD_ICON_PROPS = {
|
||||
style: { color: "var(--fn-text-tertiary)" },
|
||||
};
|
||||
|
||||
function policyPayload(values: PolicyFormValues): {
|
||||
workspace_root_dir: string | null;
|
||||
token_quota: number | null;
|
||||
} {
|
||||
return {
|
||||
workspace_root_dir: values.limit_workspace_root
|
||||
? values.workspace_root_dir?.trim() || null
|
||||
: null,
|
||||
token_quota: values.limit_token_quota ? values.token_quota ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
function ResourcePolicyFields({ fsTreeRoot }: { fsTreeRoot: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`${styles.createSection} ${styles.policySection}`}>
|
||||
<div className={styles.createSectionTitle}>
|
||||
{t("adminUsers.createSectionPolicy")}
|
||||
</div>
|
||||
<Form.Item
|
||||
label={t("adminUsers.policyWorkspaceRoot")}
|
||||
extra={t("adminUsers.policyWorkspaceRootHint", {
|
||||
localShell: t("experts.backendModes.localShell"),
|
||||
filesystem: t("experts.backendModes.filesystem"),
|
||||
})}
|
||||
>
|
||||
<Form.Item name="limit_workspace_root" valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev.limit_workspace_root !== cur.limit_workspace_root
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("limit_workspace_root") ? (
|
||||
<Form.Item
|
||||
name="workspace_root_dir"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("adminUsers.policyWorkspaceRootRequired"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RootDirSelect treeRoot={fsTreeRoot} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("adminUsers.policyTokenQuota")}
|
||||
extra={t("adminUsers.policyTokenQuotaHint")}
|
||||
>
|
||||
<Form.Item name="limit_token_quota" valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev.limit_token_quota !== cur.limit_token_quota
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("limit_token_quota") ? (
|
||||
<Form.Item
|
||||
name="token_quota"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("adminUsers.policyTokenQuotaRequired"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} step={1000} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RolePickerProps {
|
||||
value?: "admin" | "user";
|
||||
onChange?: (value: "admin" | "user") => void;
|
||||
@@ -980,6 +1069,7 @@ export default function UsersListPanel() {
|
||||
password: values.password,
|
||||
role: values.role,
|
||||
permissions: values.role === "admin" ? [] : values.permissions ?? [],
|
||||
...policyPayload(values),
|
||||
}),
|
||||
});
|
||||
message.success(
|
||||
@@ -1006,6 +1096,10 @@ export default function UsersListPanel() {
|
||||
email: undefined,
|
||||
password: undefined,
|
||||
confirm: undefined,
|
||||
limit_workspace_root: false,
|
||||
workspace_root_dir: undefined,
|
||||
limit_token_quota: false,
|
||||
token_quota: undefined,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1062,12 +1156,7 @@ export default function UsersListPanel() {
|
||||
email: values.email?.trim() || null,
|
||||
role: values.role,
|
||||
permissions: values.role === "admin" ? [] : values.permissions ?? [],
|
||||
workspace_root_dir: values.limit_workspace_root
|
||||
? values.workspace_root_dir?.trim() || null
|
||||
: null,
|
||||
token_quota: values.limit_token_quota
|
||||
? values.token_quota ?? null
|
||||
: null,
|
||||
...policyPayload(values),
|
||||
}),
|
||||
});
|
||||
setEditTarget(null);
|
||||
@@ -1511,7 +1600,12 @@ export default function UsersListPanel() {
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={onCreate}
|
||||
initialValues={{ role: "user", permissions: [] }}
|
||||
initialValues={{
|
||||
role: "user",
|
||||
permissions: [],
|
||||
limit_workspace_root: false,
|
||||
limit_token_quota: false,
|
||||
}}
|
||||
className={styles.createUserForm}
|
||||
>
|
||||
<div className={styles.createSection}>
|
||||
@@ -1637,6 +1731,8 @@ export default function UsersListPanel() {
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<ResourcePolicyFields fsTreeRoot={fsTreeRoot} />
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -1769,86 +1865,7 @@ export default function UsersListPanel() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.createSection} ${styles.policySection}`}>
|
||||
<div className={styles.createSectionTitle}>
|
||||
{t("adminUsers.createSectionPolicy")}
|
||||
</div>
|
||||
<Form.Item
|
||||
label={t("adminUsers.policyWorkspaceRoot")}
|
||||
extra={t("adminUsers.policyWorkspaceRootHint", {
|
||||
localShell: t("experts.backendModes.localShell"),
|
||||
filesystem: t("experts.backendModes.filesystem"),
|
||||
})}
|
||||
>
|
||||
<Form.Item
|
||||
name="limit_workspace_root"
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev.limit_workspace_root !== cur.limit_workspace_root
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("limit_workspace_root") ? (
|
||||
<Form.Item
|
||||
name="workspace_root_dir"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("adminUsers.policyWorkspaceRootRequired"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RootDirSelect treeRoot={fsTreeRoot} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("adminUsers.policyTokenQuota")}
|
||||
extra={t("adminUsers.policyTokenQuotaHint")}
|
||||
>
|
||||
<Form.Item
|
||||
name="limit_token_quota"
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev.limit_token_quota !== cur.limit_token_quota
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("limit_token_quota") ? (
|
||||
<Form.Item
|
||||
name="token_quota"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("adminUsers.policyTokenQuotaRequired"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1000}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</div>
|
||||
<ResourcePolicyFields fsTreeRoot={fsTreeRoot} />
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ routes until the wizard finishes.
|
||||
| Method | Path | Auth | Notes |
|
||||
|--------|------|------|-------|
|
||||
| `GET` | `/users` | admin | `[{id, username, role, display_name, email, enabled, ...}]` |
|
||||
| `POST` | `/users` | admin | body `{username, password, role, display_name?, email?}` → `201` |
|
||||
| `POST` | `/users` | admin | body `{username, password, role, display_name?, email?, permissions?, workspace_root_dir?, token_quota?}` → `201` |
|
||||
| `GET` | `/users/{id}` | admin | full user row |
|
||||
| `PATCH` | `/users/{id}` | admin | body subset of `{role, display_name, email, enabled, locale}` |
|
||||
| `POST` | `/users/{id}/reset-password` | admin | body `{new_password}` → `204` |
|
||||
|
||||
@@ -12,7 +12,11 @@ from octop.api.deps import current_user, get_server, require_permission
|
||||
from octop.infra.errors import ErrorCode, OctopError
|
||||
from octop.infra.users.identity import Role, User
|
||||
from octop.infra.users.permissions import PERMISSIONS
|
||||
from octop.infra.users.resource_policy import public_policy_fields
|
||||
from octop.infra.users.resource_policy import (
|
||||
normalize_token_quota,
|
||||
normalize_workspace_root_dir,
|
||||
public_policy_fields,
|
||||
)
|
||||
from octop.infra.utils.locale import resolve_request_locale
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,6 +29,8 @@ class UserCreateBody(BaseModel):
|
||||
display_name: str | None = None
|
||||
email: str | None = Field(default=None, max_length=254)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
workspace_root_dir: str | None = None
|
||||
token_quota: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class UserPatchBody(BaseModel):
|
||||
@@ -65,6 +71,15 @@ def _row_to_dict(r: Any, policy: Any | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _policy_kwargs_from_body(body: UserCreateBody | UserPatchBody) -> dict[str, Any]:
|
||||
policy_kwargs: dict[str, Any] = {}
|
||||
if "workspace_root_dir" in body.model_fields_set:
|
||||
policy_kwargs["workspace_root_dir"] = body.workspace_root_dir
|
||||
if "token_quota" in body.model_fields_set:
|
||||
policy_kwargs["token_quota"] = body.token_quota
|
||||
return policy_kwargs
|
||||
|
||||
|
||||
def _assert_can_assign(actor: User, permissions: list[str]) -> None:
|
||||
"""Non-admin actors may only grant permissions they themselves hold."""
|
||||
if actor.is_admin:
|
||||
@@ -148,6 +163,11 @@ async def create_user(
|
||||
server: Any = Depends(get_server),
|
||||
) -> dict[str, Any]:
|
||||
_assert_can_assign(actor, body.permissions)
|
||||
policy_kwargs = _policy_kwargs_from_body(body)
|
||||
if "workspace_root_dir" in policy_kwargs:
|
||||
normalize_workspace_root_dir(policy_kwargs["workspace_root_dir"])
|
||||
if "token_quota" in policy_kwargs:
|
||||
normalize_token_quota(policy_kwargs["token_quota"])
|
||||
role = Role(body.role)
|
||||
user = await server.user_manager.create(
|
||||
username=body.username,
|
||||
@@ -157,6 +177,8 @@ async def create_user(
|
||||
email=body.email,
|
||||
permissions=body.permissions,
|
||||
)
|
||||
if policy_kwargs:
|
||||
await server.user_manager.set_resource_policy(user.username, **policy_kwargs)
|
||||
row = server.user_manager.get_row(user.id)
|
||||
assert row is not None
|
||||
return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id))
|
||||
@@ -206,11 +228,7 @@ async def patch_user(
|
||||
await server.user_manager.enable(row.username)
|
||||
if body.permissions is not None:
|
||||
await server.user_manager.set_permissions(row.username, body.permissions)
|
||||
policy_kwargs: dict[str, Any] = {}
|
||||
if "workspace_root_dir" in body.model_fields_set:
|
||||
policy_kwargs["workspace_root_dir"] = body.workspace_root_dir
|
||||
if "token_quota" in body.model_fields_set:
|
||||
policy_kwargs["token_quota"] = body.token_quota
|
||||
policy_kwargs = _policy_kwargs_from_body(body)
|
||||
if policy_kwargs:
|
||||
await server.user_manager.set_resource_policy(row.username, **policy_kwargs)
|
||||
updated = server.user_manager.get_row(user_id)
|
||||
|
||||
@@ -8,6 +8,7 @@ CronTaskType = Literal["text", "agent"]
|
||||
DEFAULT_CRON_TASK_TYPE: CronTaskType = "agent"
|
||||
CRON_PROMPT_MAX_LEN = 2000
|
||||
CRON_NAME_MAX_LEN = 80
|
||||
CRON_NAME_FALLBACK_LEN = 40
|
||||
_CRON_TASK_TYPES = frozenset({"text", "agent"})
|
||||
|
||||
|
||||
@@ -36,15 +37,16 @@ def require_cron_prompt(prompt: str) -> str:
|
||||
|
||||
|
||||
def default_cron_name(prompt: str, cron_id: str) -> str:
|
||||
"""Build a readable fallback name from the prompt or id."""
|
||||
text = " ".join(prompt.strip().split())
|
||||
if not text:
|
||||
return cron_id
|
||||
return text[:40]
|
||||
"""Build a readable fallback name from the prompt prefix, else *cron_id*."""
|
||||
for line in prompt.splitlines():
|
||||
text = " ".join(line.strip().split())
|
||||
if text:
|
||||
return text[:CRON_NAME_FALLBACK_LEN]
|
||||
return cron_id
|
||||
|
||||
|
||||
def require_cron_name(name: str | None, *, prompt: str, cron_id: str) -> str:
|
||||
"""Validate cron display name and fill a stable fallback when omitted."""
|
||||
"""Return a non-empty display name; blank input falls back to the prompt prefix."""
|
||||
text = (name or "").strip() or default_cron_name(prompt, cron_id)
|
||||
if len(text) > CRON_NAME_MAX_LEN:
|
||||
raise ValueError(f"name must be at most {CRON_NAME_MAX_LEN} characters")
|
||||
|
||||
@@ -11,6 +11,7 @@ from pydantic import Field
|
||||
|
||||
from octop.infra.cron.manager import CronCreateSpec
|
||||
from octop.infra.cron.task_type import (
|
||||
CRON_NAME_MAX_LEN,
|
||||
require_cron_name,
|
||||
require_cron_prompt,
|
||||
require_cron_task_type,
|
||||
@@ -37,9 +38,11 @@ _TASK_TYPE_HELP = (
|
||||
_CRONJOB_CREATE_DESC = (
|
||||
"Create a scheduled cron job bound to the current conversation session. "
|
||||
"Results are delivered to the same channel (QQ/WeChat/dashboard/…). "
|
||||
"name is required: a short label for the job list. "
|
||||
f"task_type: {_TASK_TYPE_HELP} "
|
||||
"Examples: 'remind me to drink water at 14:00 daily' → task_type=text, prompt='该喝水了💧'; "
|
||||
"'summarize my inbox every morning' → task_type=agent. "
|
||||
"Examples: 'remind me to drink water at 14:00 daily' → "
|
||||
"name='Daily water reminder', task_type=text, prompt='Time to drink water'; "
|
||||
"'summarize my inbox every morning' → name='Morning inbox summary', task_type=agent. "
|
||||
f"trigger: {_TRIGGER_HELP}"
|
||||
)
|
||||
|
||||
@@ -128,9 +131,15 @@ def build_cronjob_tools(cron_manager: CronManager) -> list[StructuredTool]:
|
||||
),
|
||||
],
|
||||
name: Annotated[
|
||||
str | None,
|
||||
Field(description="Optional display name for this cron job."),
|
||||
] = None,
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Required short display name shown in the job list "
|
||||
f"(max {CRON_NAME_MAX_LEN} characters). "
|
||||
"Summarize the user's request, e.g. 'Daily water reminder'."
|
||||
),
|
||||
),
|
||||
],
|
||||
fresh_thread: Annotated[
|
||||
bool,
|
||||
Field(description="If true, reset conversation context before each agent run."),
|
||||
|
||||
@@ -105,6 +105,52 @@ async def test_admin_can_unlock_login(env):
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_admin_can_create_user_with_resource_policy(env, tmp_path):
|
||||
c, _srv, auth = env
|
||||
jail = tmp_path / "jail"
|
||||
jail.mkdir()
|
||||
|
||||
r = await c.post(
|
||||
"/api/users",
|
||||
headers=auth,
|
||||
json={
|
||||
"username": "policy_create",
|
||||
"password": "TestPass12",
|
||||
"role": "user",
|
||||
"workspace_root_dir": jail.as_posix(),
|
||||
"token_quota": 2000,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["workspace_root_dir"] == jail.resolve().as_posix()
|
||||
assert body["token_quota"] == 2000
|
||||
|
||||
listed = (await c.get("/api/users", headers=auth)).json()
|
||||
row = next(u for u in listed if u["username"] == "policy_create")
|
||||
assert row["workspace_root_dir"] == jail.resolve().as_posix()
|
||||
assert row["token_quota"] == 2000
|
||||
|
||||
|
||||
async def test_create_user_rejects_invalid_workspace_root(env, tmp_path):
|
||||
c, _srv, auth = env
|
||||
missing = tmp_path / "no-such-dir"
|
||||
|
||||
r = await c.post(
|
||||
"/api/users",
|
||||
headers=auth,
|
||||
json={
|
||||
"username": "bad_root",
|
||||
"password": "TestPass12",
|
||||
"role": "user",
|
||||
"workspace_root_dir": missing.as_posix(),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
listed = (await c.get("/api/users", headers=auth)).json()
|
||||
assert "bad_root" not in [u["username"] for u in listed]
|
||||
|
||||
|
||||
async def test_admin_can_set_resource_policy(env, tmp_path):
|
||||
from tests.support.auth import TEST_PASSWORD, create_user
|
||||
|
||||
|
||||
@@ -80,10 +80,12 @@ async def test_cronjob_create_and_list(tmp_path: Path) -> None:
|
||||
{
|
||||
"trigger": "interval:120",
|
||||
"prompt": "say hi",
|
||||
"name": "say hi",
|
||||
}
|
||||
)
|
||||
data = json.loads(out)
|
||||
assert data["prompt"] == "say hi"
|
||||
assert data["name"] == "say hi"
|
||||
assert data["trigger"] == "interval:120"
|
||||
assert data["task_type"] == "text"
|
||||
cron_id = data["id"]
|
||||
@@ -109,7 +111,9 @@ async def test_cronjob_isolated_by_agent_and_user(tmp_path: Path) -> None:
|
||||
list_tool = _tool_by_name(tools, "cronjob_list")
|
||||
|
||||
with _configurable(agent_id=agent_a, user=str(uid)):
|
||||
created = json.loads(await create.ainvoke({"trigger": "interval:60", "prompt": "a"}))
|
||||
created = json.loads(
|
||||
await create.ainvoke({"trigger": "interval:60", "prompt": "a", "name": "a"})
|
||||
)
|
||||
cron_id = created["id"]
|
||||
|
||||
with _configurable(agent_id=agent_b, user=str(uid)):
|
||||
@@ -121,7 +125,9 @@ async def test_cronjob_isolated_by_agent_and_user(tmp_path: Path) -> None:
|
||||
assert "error" in err
|
||||
listed = json.loads(await list_tool.ainvoke({"include_disabled": True}))
|
||||
assert listed == []
|
||||
created = json.loads(await create.ainvoke({"trigger": "interval:60", "prompt": "nope"}))
|
||||
created = json.loads(
|
||||
await create.ainvoke({"trigger": "interval:60", "prompt": "nope", "name": "nope"})
|
||||
)
|
||||
assert "error" in created
|
||||
|
||||
|
||||
@@ -137,7 +143,9 @@ async def test_cronjob_delete(tmp_path: Path) -> None:
|
||||
delete = _tool_by_name(tools, "cronjob_delete")
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id)):
|
||||
created = json.loads(await create.ainvoke({"trigger": "interval:30", "prompt": "p"}))
|
||||
created = json.loads(
|
||||
await create.ainvoke({"trigger": "interval:30", "prompt": "p", "name": "p"})
|
||||
)
|
||||
cron_id = created["id"]
|
||||
out = json.loads(await delete.ainvoke({"cron_id": cron_id}))
|
||||
assert out["deleted"] == cron_id
|
||||
@@ -157,7 +165,9 @@ async def test_cronjob_run_now(tmp_path: Path) -> None:
|
||||
run_now = _tool_by_name(tools, "cronjob_run_now")
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id)):
|
||||
created = json.loads(await create.ainvoke({"trigger": "interval:30", "prompt": "p"}))
|
||||
created = json.loads(
|
||||
await create.ainvoke({"trigger": "interval:30", "prompt": "p", "name": "p"})
|
||||
)
|
||||
cron_id = created["id"]
|
||||
out = json.loads(await run_now.ainvoke({"cron_id": cron_id}))
|
||||
assert out["triggered"] == cron_id
|
||||
@@ -181,7 +191,7 @@ async def test_cronjob_create_uses_configurable_session_key(tmp_path: Path) -> N
|
||||
)
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id), session_key=feishu_sk):
|
||||
out = await create.ainvoke({"trigger": "interval:30", "prompt": "ping"})
|
||||
out = await create.ainvoke({"trigger": "interval:30", "prompt": "ping", "name": "ping"})
|
||||
data = json.loads(out)
|
||||
assert data["session_key"] == feishu_sk
|
||||
|
||||
@@ -198,10 +208,46 @@ async def test_cronjob_create_persists_task_type(tmp_path: Path) -> None:
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id)):
|
||||
out = await create.ainvoke(
|
||||
{"trigger": "interval:30", "prompt": "ping", "task_type": "text"}
|
||||
{"trigger": "interval:30", "prompt": "ping", "name": "ping", "task_type": "text"}
|
||||
)
|
||||
data = json.loads(out)
|
||||
assert data["task_type"] == "text"
|
||||
row = mgr.get(data["id"])
|
||||
assert row is not None
|
||||
assert row.task_type == "text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cronjob_create_requires_name(tmp_path: Path) -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
services = _make_services(tmp_path)
|
||||
agent_id = new_ulid()
|
||||
user_id = services.repos.user_repo.create(username="req", password_hash="x", role="user")
|
||||
services.repos.agent_repo.create(agent_id=agent_id, user_id=user_id, name="a")
|
||||
mgr = _make_manager(services)
|
||||
create = _tool_by_name(build_cronjob_tools(mgr), "cronjob_create")
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id)), pytest.raises(ValidationError):
|
||||
await create.ainvoke({"trigger": "interval:30", "prompt": "ping"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cronjob_create_blank_name_uses_prompt_prefix(tmp_path: Path) -> None:
|
||||
services = _make_services(tmp_path)
|
||||
agent_id = new_ulid()
|
||||
user_id = services.repos.user_repo.create(username="fb", password_hash="x", role="user")
|
||||
services.repos.agent_repo.create(agent_id=agent_id, user_id=user_id, name="a")
|
||||
mgr = _make_manager(services)
|
||||
create = _tool_by_name(build_cronjob_tools(mgr), "cronjob_create")
|
||||
|
||||
with _configurable(agent_id=agent_id, user=str(user_id)):
|
||||
out = await create.ainvoke(
|
||||
{
|
||||
"trigger": "interval:30",
|
||||
"prompt": "该喝水了💧\n记得站起来活动一下",
|
||||
"name": " ",
|
||||
}
|
||||
)
|
||||
data = json.loads(out)
|
||||
assert data["name"] == "该喝水了💧"
|
||||
|
||||
@@ -4,7 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from octop.infra.cron.task_type import CRON_PROMPT_MAX_LEN, require_cron_prompt
|
||||
from octop.infra.cron.task_type import (
|
||||
CRON_NAME_FALLBACK_LEN,
|
||||
CRON_NAME_MAX_LEN,
|
||||
CRON_PROMPT_MAX_LEN,
|
||||
default_cron_name,
|
||||
require_cron_name,
|
||||
require_cron_prompt,
|
||||
)
|
||||
|
||||
|
||||
def test_require_cron_prompt_rejects_empty() -> None:
|
||||
@@ -19,3 +26,29 @@ def test_require_cron_prompt_rejects_too_long() -> None:
|
||||
|
||||
def test_require_cron_prompt_strips() -> None:
|
||||
assert require_cron_prompt(" hello ") == "hello"
|
||||
|
||||
|
||||
def test_default_cron_name_uses_first_line() -> None:
|
||||
assert default_cron_name("该喝水了\n更多说明", "cron_x") == "该喝水了"
|
||||
|
||||
|
||||
def test_default_cron_name_truncates_long_prefix() -> None:
|
||||
prompt = "a" * (CRON_NAME_FALLBACK_LEN + 10)
|
||||
assert default_cron_name(prompt, "cron_x") == "a" * CRON_NAME_FALLBACK_LEN
|
||||
|
||||
|
||||
def test_default_cron_name_falls_back_to_cron_id() -> None:
|
||||
assert default_cron_name(" \n ", "cron_x") == "cron_x"
|
||||
|
||||
|
||||
def test_require_cron_name_uses_explicit_name() -> None:
|
||||
assert require_cron_name(" 喝水 ", prompt="ignored", cron_id="cron_x") == "喝水"
|
||||
|
||||
|
||||
def test_require_cron_name_blank_uses_prompt_prefix() -> None:
|
||||
assert require_cron_name(" ", prompt="站起来活动", cron_id="cron_x") == "站起来活动"
|
||||
|
||||
|
||||
def test_require_cron_name_rejects_too_long() -> None:
|
||||
with pytest.raises(ValueError, match=str(CRON_NAME_MAX_LEN)):
|
||||
require_cron_name("x" * (CRON_NAME_MAX_LEN + 1), prompt="p", cron_id="cron_x")
|
||||
|
||||
Reference in New Issue
Block a user