feat(agent): add agent management UI, skills/tools/triggers, and issue assignment
- Complete agents management page with create dialog, runtime device selector, skills/tools/triggers/tasks tabs, and agent detail view - Add AssigneePicker to issue detail page for assigning to members or agents - Extend agent types with description, skills, tools, triggers, RuntimeDevice - Add SDK methods for agent CRUD and task listing - Add migration 002 for agent config columns (skills, tools, triggers) - Update seed data with realistic agent configurations - Use auth context as single source of truth for agents (fixes state sync) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
88ca7848b5
commit
b4303f9bec
10 changed files with 1415 additions and 122 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import { use, useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Send,
|
||||
UserCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Issue, Comment } from "@multica/types";
|
||||
import type { Issue, Comment, IssueAssigneeType } from "@multica/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/config";
|
||||
import { StatusIcon, PriorityIcon } from "../page";
|
||||
import { api } from "../../../../lib/api";
|
||||
|
|
@ -79,12 +81,19 @@ function ActorAvatar({
|
|||
function PropRow({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors ${
|
||||
onClick ? "cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-20 shrink-0 text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[13px]">
|
||||
{children}
|
||||
|
|
@ -93,6 +102,151 @@ function PropRow({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignee Picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AssigneePicker({
|
||||
issue,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
issue: Issue;
|
||||
onSelect: (type: IssueAssigneeType | null, id: string | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { members, agents } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
issue.assignee_type === type && issue.assignee_id === id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-lg border bg-popover shadow-md"
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-1 pb-1">
|
||||
{/* Unassign option */}
|
||||
{issue.assignee_id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onSelect(null, null)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassign</span>
|
||||
</button>
|
||||
<div className="my-1 border-t mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
onClick={() => onSelect("member", m.user_id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("member", m.user_id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{m.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{m.name}</div>
|
||||
</div>
|
||||
{isSelected("member", m.user_id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 mt-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onSelect("agent", a.id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("agent", a.id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{a.name}</div>
|
||||
</div>
|
||||
{isSelected("agent", a.id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -109,6 +263,7 @@ export default function IssueDetailPage({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showAssigneePicker, setShowAssigneePicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
|
|
@ -138,6 +293,31 @@ export default function IssueDetailPage({
|
|||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = async (
|
||||
type: IssueAssigneeType | null,
|
||||
assigneeId: string | null,
|
||||
) => {
|
||||
if (!issue) return;
|
||||
setShowAssigneePicker(false);
|
||||
// Optimistic update
|
||||
setIssue({
|
||||
...issue,
|
||||
assignee_type: type,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
try {
|
||||
const updated = await api.updateIssue(id, {
|
||||
assignee_type: type,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
setIssue(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to update assignee:", err);
|
||||
// Revert on error
|
||||
setIssue(issue);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
|
|
@ -259,20 +439,33 @@ export default function IssueDetailPage({
|
|||
<span>{priorityCfg.label}</span>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Assignee">
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
<div className="relative">
|
||||
<PropRow
|
||||
label="Assignee"
|
||||
onClick={() => setShowAssigneePicker(!showAssigneePicker)}
|
||||
>
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</PropRow>
|
||||
|
||||
{showAssigneePicker && (
|
||||
<AssigneePicker
|
||||
issue={issue}
|
||||
onSelect={handleAssigneeChange}
|
||||
onClose={() => setShowAssigneePicker(false)}
|
||||
/>
|
||||
)}
|
||||
</PropRow>
|
||||
</div>
|
||||
|
||||
<PropRow label="Due date">
|
||||
{issue.due_date ? (
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const mockAgents: Agent[] = [
|
|||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Claude",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
|
|
@ -84,6 +85,9 @@ const mockAgents: Agent[] = [
|
|||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: "",
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const mockAgents: Agent[] = [
|
|||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Claude Agent",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
|
|
@ -51,6 +52,9 @@ export const mockAgents: Agent[] = [
|
|||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: "",
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import type {
|
|||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
InboxItem,
|
||||
Comment,
|
||||
Workspace,
|
||||
|
|
@ -151,6 +154,28 @@ export class ApiClient {
|
|||
return this.fetch(`/api/agents/${id}`);
|
||||
}
|
||||
|
||||
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
||||
return this.fetch("/api/agents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
|
|
|
|||
|
|
@ -4,10 +4,51 @@ export type AgentRuntimeMode = "local" | "cloud";
|
|||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
export type AgentTriggerType = "on_assign" | "scheduled";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
status: "online" | "offline";
|
||||
device_info: string;
|
||||
}
|
||||
|
||||
export interface AgentTool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
auth_type: "oauth" | "api_key" | "none";
|
||||
connected: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTrigger {
|
||||
id: string;
|
||||
type: AgentTriggerType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
|
||||
priority: number;
|
||||
dispatched_at: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
avatar_url: string | null;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
|
|
@ -15,6 +56,35 @@ export interface Agent {
|
|||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
owner_id: string | null;
|
||||
skills: string;
|
||||
tools: AgentTool[];
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar_url?: string;
|
||||
runtime_mode?: AgentRuntimeMode;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
avatar_url?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||
export type { Agent, AgentStatus, AgentRuntimeMode, AgentVisibility } from "./agent.js";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTriggerType,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTask,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./agent.js";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
|
||||
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
|
||||
|
|
|
|||
|
|
@ -62,13 +62,45 @@ func main() {
|
|||
// Create some agents
|
||||
agents := []struct {
|
||||
name string
|
||||
description string
|
||||
runtimeMode string
|
||||
status string
|
||||
skills string
|
||||
tools string
|
||||
triggers string
|
||||
}{
|
||||
{"Claude-1", "cloud", "idle"},
|
||||
{"Claude-2", "cloud", "working"},
|
||||
{"Local Agent", "local", "offline"},
|
||||
{"Code Review Bot", "cloud", "idle"},
|
||||
{
|
||||
"Deep Research Agent",
|
||||
"Performs deep research on topics using web search and analysis",
|
||||
"local", "idle",
|
||||
"# Deep Research Agent\n\nYou are a research agent that performs thorough analysis on assigned topics.\n\n## Workflow\n1. Break down the research question into sub-questions\n2. Use web search to gather information from multiple sources\n3. Cross-reference and validate findings\n4. Synthesize a comprehensive report\n5. Post the report as a comment on the issue\n\n## Output Format\nAlways produce a structured report with:\n- Executive Summary\n- Key Findings\n- Sources\n- Recommendations",
|
||||
`[{"id":"tool-1","name":"Google Search","description":"Search the web for information","auth_type":"api_key","connected":true,"config":{}},{"id":"tool-2","name":"Web Scraper","description":"Extract content from web pages","auth_type":"none","connected":true,"config":{}}]`,
|
||||
`[{"id":"trigger-1","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
{
|
||||
"Code Review Bot",
|
||||
"Reviews pull requests and provides feedback on code quality",
|
||||
"cloud", "idle",
|
||||
"# Code Review Bot\n\nYou review code changes and provide constructive feedback.\n\n## Review Criteria\n- Code correctness and logic\n- Performance implications\n- Security vulnerabilities\n- Code style and readability\n- Test coverage\n\n## Process\n1. Read the issue description for context\n2. Analyze code changes\n3. Post review comments on specific lines\n4. Provide an overall summary",
|
||||
`[{"id":"tool-3","name":"GitHub","description":"Access GitHub repositories and PRs","auth_type":"oauth","connected":true,"config":{}}]`,
|
||||
`[{"id":"trigger-2","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
{
|
||||
"Daily Standup Bot",
|
||||
"Generates daily standup summaries from recent activity",
|
||||
"cloud", "working",
|
||||
"# Daily Standup Bot\n\nGenerate a daily standup summary based on workspace activity.\n\n## Tasks\n1. Collect all issue status changes from the last 24 hours\n2. Summarize what each team member worked on\n3. Identify blocked items\n4. Post the summary to the team channel",
|
||||
`[{"id":"tool-4","name":"Slack","description":"Send messages to Slack channels","auth_type":"oauth","connected":true,"config":{"channel":"#standup"}}]`,
|
||||
`[{"id":"trigger-3","type":"scheduled","enabled":true,"config":{"cron":"0 9 * * 1-5","timezone":"Asia/Shanghai"}}]`,
|
||||
},
|
||||
{
|
||||
"Local Dev Agent",
|
||||
"A local development agent running on your machine",
|
||||
"local", "offline",
|
||||
"",
|
||||
`[]`,
|
||||
`[{"id":"trigger-4","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, a := range agents {
|
||||
|
|
@ -82,10 +114,10 @@ func main() {
|
|||
continue
|
||||
}
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO agent (workspace_id, name, runtime_mode, status, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO agent (workspace_id, name, description, runtime_mode, status, owner_id, skills, tools, triggers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)
|
||||
RETURNING id
|
||||
`, workspaceID, a.name, a.runtimeMode, a.status, userID).Scan(&agentID)
|
||||
`, workspaceID, a.name, a.description, a.runtimeMode, a.status, userID, a.skills, a.tools, a.triggers).Scan(&agentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create agent %s: %v", a.name, err)
|
||||
continue
|
||||
|
|
|
|||
5
server/migrations/002_agent_config.down.sql
Normal file
5
server/migrations/002_agent_config.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE agent
|
||||
DROP COLUMN IF EXISTS description,
|
||||
DROP COLUMN IF EXISTS skills,
|
||||
DROP COLUMN IF EXISTS tools,
|
||||
DROP COLUMN IF EXISTS triggers;
|
||||
6
server/migrations/002_agent_config.up.sql
Normal file
6
server/migrations/002_agent_config.up.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Add agent configuration columns: skills, tools, triggers
|
||||
ALTER TABLE agent
|
||||
ADD COLUMN description TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN skills TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN tools JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN triggers JSONB NOT NULL DEFAULT '[]';
|
||||
Loading…
Add table
Add a link
Reference in a new issue