writbase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/dist/writbase.js +707 -0
- package/migrations/00001_enums.sql +7 -0
- package/migrations/00002_core_tables.sql +105 -0
- package/migrations/00003_constraints_indexes.sql +69 -0
- package/migrations/00004_rls_policies.sql +169 -0
- package/migrations/00005_seed_app_settings.sql +2 -0
- package/migrations/00006_rate_limit_rpc.sql +14 -0
- package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
- package/migrations/00008_indexes_and_cleanup.sql +22 -0
- package/migrations/00009_atomic_mutations.sql +175 -0
- package/migrations/00010_user_rate_limits.sql +52 -0
- package/migrations/00011_atomic_permission_update.sql +31 -0
- package/migrations/00012_pg_cron_cleanup.sql +27 -0
- package/migrations/00013_max_agent_keys.sql +4 -0
- package/migrations/00014_event_log_indexes.sql +2 -0
- package/migrations/00015_auth_rate_limits.sql +69 -0
- package/migrations/00016_request_log.sql +42 -0
- package/migrations/00017_task_search.sql +58 -0
- package/migrations/00018_inter_agent_exchange.sql +423 -0
- package/migrations/00019_workspaces.sql +782 -0
- package/migrations/00020_can_comment.sql +46 -0
- package/migrations/00021_webhook_delivery.sql +125 -0
- package/migrations/00022_task_archive.sql +380 -0
- package/migrations/00023_get_top_tasks.sql +21 -0
- package/migrations/00024_agent_key_defaults.sql +55 -0
- package/migrations/00025_production_automation.sql +353 -0
- package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
- package/migrations/00027_rename_agent_key_defaults.sql +51 -0
- package/package.json +34 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- pg_cron scheduled cleanup for rate_limits table
|
|
2
|
+
--
|
|
3
|
+
-- PREREQUISITE: pg_cron must be enabled via the Supabase Dashboard
|
|
4
|
+
-- (Database → Extensions → pg_cron) before running this migration.
|
|
5
|
+
-- On local dev (where pg_cron is not available), this migration is a no-op.
|
|
6
|
+
|
|
7
|
+
DO $outer$
|
|
8
|
+
BEGIN
|
|
9
|
+
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
|
|
10
|
+
-- Run cleanup_rate_limits() every 5 minutes to purge expired windows
|
|
11
|
+
PERFORM cron.schedule(
|
|
12
|
+
'cleanup-rate-limits',
|
|
13
|
+
'*/5 * * * *',
|
|
14
|
+
'SELECT public.cleanup_rate_limits()'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
-- Purge cron.job_run_details older than 7 days, daily at 03:00 UTC
|
|
18
|
+
PERFORM cron.schedule(
|
|
19
|
+
'cleanup-cron-history',
|
|
20
|
+
'0 3 * * *',
|
|
21
|
+
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'$$
|
|
22
|
+
);
|
|
23
|
+
ELSE
|
|
24
|
+
RAISE NOTICE 'pg_cron not available — skipping cron schedule setup';
|
|
25
|
+
END IF;
|
|
26
|
+
END;
|
|
27
|
+
$outer$;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Pre-auth rate limiting by IP address.
|
|
2
|
+
-- Tracks failed authentication attempts per IP per minute window.
|
|
3
|
+
-- Separate from rate_limits (which requires an existing agent_key FK).
|
|
4
|
+
|
|
5
|
+
CREATE TABLE auth_rate_limits (
|
|
6
|
+
ip_address text NOT NULL,
|
|
7
|
+
window_start timestamptz NOT NULL,
|
|
8
|
+
failure_count integer DEFAULT 1 NOT NULL,
|
|
9
|
+
PRIMARY KEY (ip_address, window_start)
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
ALTER TABLE auth_rate_limits ENABLE ROW LEVEL SECURITY;
|
|
13
|
+
|
|
14
|
+
-- No RLS SELECT policies — only accessed via SECURITY DEFINER RPCs.
|
|
15
|
+
|
|
16
|
+
-- Atomically increment the failure counter for an IP + minute window.
|
|
17
|
+
CREATE OR REPLACE FUNCTION increment_auth_rate_limit(p_ip text)
|
|
18
|
+
RETURNS integer
|
|
19
|
+
LANGUAGE sql
|
|
20
|
+
VOLATILE
|
|
21
|
+
SECURITY DEFINER
|
|
22
|
+
SET search_path = public
|
|
23
|
+
AS $$
|
|
24
|
+
INSERT INTO auth_rate_limits (ip_address, window_start, failure_count)
|
|
25
|
+
VALUES (p_ip, date_trunc('minute', now()), 1)
|
|
26
|
+
ON CONFLICT (ip_address, window_start)
|
|
27
|
+
DO UPDATE SET failure_count = auth_rate_limits.failure_count + 1
|
|
28
|
+
RETURNING failure_count;
|
|
29
|
+
$$;
|
|
30
|
+
|
|
31
|
+
-- Check the current failure count for an IP in the current minute window.
|
|
32
|
+
CREATE OR REPLACE FUNCTION check_auth_rate_limit(p_ip text)
|
|
33
|
+
RETURNS integer
|
|
34
|
+
LANGUAGE sql
|
|
35
|
+
STABLE
|
|
36
|
+
SECURITY DEFINER
|
|
37
|
+
SET search_path = public
|
|
38
|
+
AS $$
|
|
39
|
+
SELECT COALESCE(
|
|
40
|
+
(SELECT failure_count FROM auth_rate_limits
|
|
41
|
+
WHERE ip_address = p_ip AND window_start = date_trunc('minute', now())),
|
|
42
|
+
0
|
|
43
|
+
);
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
-- Cleanup function for pg_cron: purge entries older than 1 hour
|
|
47
|
+
CREATE OR REPLACE FUNCTION cleanup_auth_rate_limits()
|
|
48
|
+
RETURNS void
|
|
49
|
+
LANGUAGE sql
|
|
50
|
+
SECURITY DEFINER
|
|
51
|
+
SET search_path = public
|
|
52
|
+
AS $$
|
|
53
|
+
DELETE FROM auth_rate_limits WHERE window_start < now() - interval '1 hour';
|
|
54
|
+
$$;
|
|
55
|
+
|
|
56
|
+
-- Schedule cleanup daily at 04:00 UTC (no-op if pg_cron not available)
|
|
57
|
+
DO $outer$
|
|
58
|
+
BEGIN
|
|
59
|
+
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
|
|
60
|
+
PERFORM cron.schedule(
|
|
61
|
+
'cleanup-auth-rate-limits',
|
|
62
|
+
'0 4 * * *',
|
|
63
|
+
'SELECT public.cleanup_auth_rate_limits()'
|
|
64
|
+
);
|
|
65
|
+
ELSE
|
|
66
|
+
RAISE NOTICE 'pg_cron not available — skipping auth rate limit cleanup schedule';
|
|
67
|
+
END IF;
|
|
68
|
+
END;
|
|
69
|
+
$outer$;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- Request log for operational telemetry.
|
|
2
|
+
-- Separate from event_log (domain audit/provenance) — different shape and lifecycle.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE request_log (
|
|
5
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
6
|
+
agent_key_id uuid NOT NULL REFERENCES agent_keys(id),
|
|
7
|
+
tool_name text NOT NULL,
|
|
8
|
+
project_id uuid REFERENCES projects(id),
|
|
9
|
+
latency_ms int,
|
|
10
|
+
status text NOT NULL,
|
|
11
|
+
error_code text,
|
|
12
|
+
created_at timestamptz DEFAULT now() NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
ALTER TABLE request_log ENABLE ROW LEVEL SECURITY;
|
|
16
|
+
|
|
17
|
+
CREATE POLICY "admin_select_request_log" ON request_log
|
|
18
|
+
FOR SELECT TO authenticated
|
|
19
|
+
USING (auth.uid() IN (SELECT user_id FROM admin_users));
|
|
20
|
+
|
|
21
|
+
-- BRIN index for time-range scans (append-only, monotonic timestamps)
|
|
22
|
+
CREATE INDEX idx_request_log_created_at ON request_log USING brin (created_at);
|
|
23
|
+
CREATE INDEX idx_request_log_agent_key_id ON request_log (agent_key_id);
|
|
24
|
+
|
|
25
|
+
-- Partial index for error investigation only
|
|
26
|
+
CREATE INDEX idx_request_log_errors ON request_log (created_at, error_code)
|
|
27
|
+
WHERE status != 'ok';
|
|
28
|
+
|
|
29
|
+
-- pg_cron cleanup: purge entries older than 30 days, daily at 04:00 UTC (no-op if pg_cron not available)
|
|
30
|
+
DO $outer$
|
|
31
|
+
BEGIN
|
|
32
|
+
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
|
|
33
|
+
PERFORM cron.schedule(
|
|
34
|
+
'cleanup-request-log',
|
|
35
|
+
'0 4 * * *',
|
|
36
|
+
$$DELETE FROM request_log WHERE created_at < now() - interval '30 days'$$
|
|
37
|
+
);
|
|
38
|
+
ELSE
|
|
39
|
+
RAISE NOTICE 'pg_cron not available — skipping request log cleanup schedule';
|
|
40
|
+
END IF;
|
|
41
|
+
END;
|
|
42
|
+
$outer$;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- Add tsvector column for full-text search
|
|
2
|
+
ALTER TABLE tasks ADD COLUMN search_vector tsvector
|
|
3
|
+
GENERATED ALWAYS AS (
|
|
4
|
+
to_tsvector('english', coalesce(description, '') || ' ' || coalesce(notes, ''))
|
|
5
|
+
) STORED;
|
|
6
|
+
|
|
7
|
+
CREATE INDEX idx_tasks_search ON tasks USING gin (search_vector);
|
|
8
|
+
|
|
9
|
+
-- Update get_tasks_page RPC to support full-text search
|
|
10
|
+
CREATE OR REPLACE FUNCTION get_tasks_page(
|
|
11
|
+
p_project_id uuid,
|
|
12
|
+
p_department_id uuid DEFAULT NULL,
|
|
13
|
+
p_status status DEFAULT NULL,
|
|
14
|
+
p_priority priority DEFAULT NULL,
|
|
15
|
+
p_updated_after timestamptz DEFAULT NULL,
|
|
16
|
+
p_cursor_created_at timestamptz DEFAULT NULL,
|
|
17
|
+
p_cursor_id uuid DEFAULT NULL,
|
|
18
|
+
p_limit integer DEFAULT 20,
|
|
19
|
+
p_search text DEFAULT NULL
|
|
20
|
+
)
|
|
21
|
+
RETURNS TABLE (
|
|
22
|
+
id uuid,
|
|
23
|
+
project_id uuid,
|
|
24
|
+
department_id uuid,
|
|
25
|
+
priority priority,
|
|
26
|
+
description text,
|
|
27
|
+
notes text,
|
|
28
|
+
due_date timestamptz,
|
|
29
|
+
status status,
|
|
30
|
+
version integer,
|
|
31
|
+
created_at timestamptz,
|
|
32
|
+
updated_at timestamptz,
|
|
33
|
+
created_by_type actor_type,
|
|
34
|
+
created_by_id text,
|
|
35
|
+
updated_by_type actor_type,
|
|
36
|
+
updated_by_id text,
|
|
37
|
+
source source
|
|
38
|
+
)
|
|
39
|
+
LANGUAGE sql
|
|
40
|
+
STABLE
|
|
41
|
+
AS $$
|
|
42
|
+
SELECT t.id, t.project_id, t.department_id, t.priority, t.description,
|
|
43
|
+
t.notes, t.due_date, t.status, t.version, t.created_at, t.updated_at,
|
|
44
|
+
t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source
|
|
45
|
+
FROM tasks t
|
|
46
|
+
WHERE t.project_id = p_project_id
|
|
47
|
+
AND (p_department_id IS NULL OR t.department_id = p_department_id)
|
|
48
|
+
AND (p_status IS NULL OR t.status = p_status)
|
|
49
|
+
AND (p_priority IS NULL OR t.priority = p_priority)
|
|
50
|
+
AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
|
|
51
|
+
AND (
|
|
52
|
+
p_cursor_created_at IS NULL
|
|
53
|
+
OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
|
|
54
|
+
)
|
|
55
|
+
AND (p_search IS NULL OR t.search_vector @@ websearch_to_tsquery('english', p_search))
|
|
56
|
+
ORDER BY t.created_at ASC, t.id ASC
|
|
57
|
+
LIMIT LEAST(p_limit, 50);
|
|
58
|
+
$$;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
-- Inter-Agent Task Exchange: schema additions for task assignment,
|
|
2
|
+
-- delegation safety, webhook notifications, and agent discovery.
|
|
3
|
+
|
|
4
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
-- 1. Add 'failed' to status enum (A2A alignment)
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
ALTER TYPE status ADD VALUE IF NOT EXISTS 'failed';
|
|
8
|
+
|
|
9
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
10
|
+
-- 2. Task assignment columns + delegation safety
|
|
11
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
12
|
+
ALTER TABLE tasks
|
|
13
|
+
ADD COLUMN assigned_to_agent_key_id uuid REFERENCES agent_keys(id),
|
|
14
|
+
ADD COLUMN requested_by_agent_key_id uuid REFERENCES agent_keys(id),
|
|
15
|
+
ADD COLUMN delegation_depth integer NOT NULL DEFAULT 0,
|
|
16
|
+
ADD COLUMN assignment_chain uuid[] NOT NULL DEFAULT '{}';
|
|
17
|
+
|
|
18
|
+
ALTER TABLE tasks
|
|
19
|
+
ADD CONSTRAINT chk_delegation_depth CHECK (delegation_depth <= 3);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_agent_key_id)
|
|
22
|
+
WHERE assigned_to_agent_key_id IS NOT NULL;
|
|
23
|
+
|
|
24
|
+
CREATE INDEX idx_tasks_requested_by ON tasks(requested_by_agent_key_id)
|
|
25
|
+
WHERE requested_by_agent_key_id IS NOT NULL;
|
|
26
|
+
|
|
27
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
28
|
+
-- 3. Add can_assign permission to agent_permissions
|
|
29
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
30
|
+
ALTER TABLE agent_permissions
|
|
31
|
+
ADD COLUMN can_assign boolean NOT NULL DEFAULT false;
|
|
32
|
+
|
|
33
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
34
|
+
-- 4. Webhook subscriptions table
|
|
35
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
36
|
+
CREATE TABLE webhook_subscriptions (
|
|
37
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
38
|
+
agent_key_id uuid NOT NULL REFERENCES agent_keys(id) ON DELETE CASCADE,
|
|
39
|
+
project_id uuid NOT NULL REFERENCES projects(id),
|
|
40
|
+
event_types text[] NOT NULL DEFAULT '{task.completed}',
|
|
41
|
+
url text NOT NULL,
|
|
42
|
+
secret text NOT NULL,
|
|
43
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
44
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
45
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX idx_webhook_subs_agent ON webhook_subscriptions(agent_key_id);
|
|
49
|
+
CREATE INDEX idx_webhook_subs_project ON webhook_subscriptions(project_id);
|
|
50
|
+
CREATE INDEX idx_webhook_subs_active ON webhook_subscriptions(project_id, is_active)
|
|
51
|
+
WHERE is_active = true;
|
|
52
|
+
|
|
53
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
54
|
+
-- 5. Agent capabilities table (A2A Agent Card shaped)
|
|
55
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
56
|
+
CREATE TABLE agent_capabilities (
|
|
57
|
+
agent_key_id uuid PRIMARY KEY REFERENCES agent_keys(id) ON DELETE CASCADE,
|
|
58
|
+
skills text[] NOT NULL DEFAULT '{}',
|
|
59
|
+
description text,
|
|
60
|
+
accepts_tasks boolean NOT NULL DEFAULT true,
|
|
61
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
62
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
66
|
+
-- 6. Update get_tasks_page to include new columns + assignment filters
|
|
67
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
68
|
+
CREATE OR REPLACE FUNCTION get_tasks_page(
|
|
69
|
+
p_project_id uuid,
|
|
70
|
+
p_department_id uuid DEFAULT NULL,
|
|
71
|
+
p_status status DEFAULT NULL,
|
|
72
|
+
p_priority priority DEFAULT NULL,
|
|
73
|
+
p_updated_after timestamptz DEFAULT NULL,
|
|
74
|
+
p_cursor_created_at timestamptz DEFAULT NULL,
|
|
75
|
+
p_cursor_id uuid DEFAULT NULL,
|
|
76
|
+
p_limit integer DEFAULT 20,
|
|
77
|
+
p_search text DEFAULT NULL,
|
|
78
|
+
p_assigned_to uuid DEFAULT NULL,
|
|
79
|
+
p_requested_by uuid DEFAULT NULL
|
|
80
|
+
)
|
|
81
|
+
RETURNS TABLE (
|
|
82
|
+
id uuid,
|
|
83
|
+
project_id uuid,
|
|
84
|
+
department_id uuid,
|
|
85
|
+
priority priority,
|
|
86
|
+
description text,
|
|
87
|
+
notes text,
|
|
88
|
+
due_date timestamptz,
|
|
89
|
+
status status,
|
|
90
|
+
version integer,
|
|
91
|
+
created_at timestamptz,
|
|
92
|
+
updated_at timestamptz,
|
|
93
|
+
created_by_type actor_type,
|
|
94
|
+
created_by_id text,
|
|
95
|
+
updated_by_type actor_type,
|
|
96
|
+
updated_by_id text,
|
|
97
|
+
source source,
|
|
98
|
+
assigned_to_agent_key_id uuid,
|
|
99
|
+
requested_by_agent_key_id uuid,
|
|
100
|
+
delegation_depth integer,
|
|
101
|
+
assignment_chain uuid[]
|
|
102
|
+
)
|
|
103
|
+
LANGUAGE sql
|
|
104
|
+
STABLE
|
|
105
|
+
AS $$
|
|
106
|
+
SELECT t.id, t.project_id, t.department_id, t.priority, t.description,
|
|
107
|
+
t.notes, t.due_date, t.status, t.version, t.created_at, t.updated_at,
|
|
108
|
+
t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source,
|
|
109
|
+
t.assigned_to_agent_key_id, t.requested_by_agent_key_id,
|
|
110
|
+
t.delegation_depth, t.assignment_chain
|
|
111
|
+
FROM tasks t
|
|
112
|
+
WHERE t.project_id = p_project_id
|
|
113
|
+
AND (p_department_id IS NULL OR t.department_id = p_department_id)
|
|
114
|
+
AND (p_status IS NULL OR t.status = p_status)
|
|
115
|
+
AND (p_priority IS NULL OR t.priority = p_priority)
|
|
116
|
+
AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
|
|
117
|
+
AND (p_search IS NULL OR t.search_vector @@ websearch_to_tsquery('english', p_search))
|
|
118
|
+
AND (p_assigned_to IS NULL OR t.assigned_to_agent_key_id = p_assigned_to)
|
|
119
|
+
AND (p_requested_by IS NULL OR t.requested_by_agent_key_id = p_requested_by)
|
|
120
|
+
AND (
|
|
121
|
+
p_cursor_created_at IS NULL
|
|
122
|
+
OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
|
|
123
|
+
)
|
|
124
|
+
ORDER BY t.created_at ASC, t.id ASC
|
|
125
|
+
LIMIT LEAST(p_limit, 50);
|
|
126
|
+
$$;
|
|
127
|
+
|
|
128
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
129
|
+
-- 7. Update create_task_with_event to support assignment fields
|
|
130
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
131
|
+
CREATE OR REPLACE FUNCTION create_task_with_event(p_payload jsonb)
|
|
132
|
+
RETURNS tasks
|
|
133
|
+
LANGUAGE plpgsql
|
|
134
|
+
SECURITY DEFINER
|
|
135
|
+
SET search_path = public
|
|
136
|
+
AS $$
|
|
137
|
+
DECLARE
|
|
138
|
+
v_project projects%ROWTYPE;
|
|
139
|
+
v_dept departments%ROWTYPE;
|
|
140
|
+
v_task tasks%ROWTYPE;
|
|
141
|
+
v_dept_id uuid;
|
|
142
|
+
v_assigned_to uuid;
|
|
143
|
+
v_requested_by uuid;
|
|
144
|
+
BEGIN
|
|
145
|
+
-- 1. Validate project exists and is not archived
|
|
146
|
+
SELECT * INTO v_project
|
|
147
|
+
FROM projects
|
|
148
|
+
WHERE id = (p_payload->>'project_id')::uuid;
|
|
149
|
+
|
|
150
|
+
IF NOT FOUND THEN
|
|
151
|
+
RAISE EXCEPTION 'project_not_found:Project not found';
|
|
152
|
+
END IF;
|
|
153
|
+
|
|
154
|
+
IF v_project.is_archived THEN
|
|
155
|
+
RAISE EXCEPTION 'project_archived:Cannot create tasks in an archived project';
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
-- 2. Validate department if provided
|
|
159
|
+
v_dept_id := (p_payload->>'department_id')::uuid;
|
|
160
|
+
IF v_dept_id IS NOT NULL THEN
|
|
161
|
+
SELECT * INTO v_dept
|
|
162
|
+
FROM departments
|
|
163
|
+
WHERE id = v_dept_id;
|
|
164
|
+
|
|
165
|
+
IF NOT FOUND THEN
|
|
166
|
+
RAISE EXCEPTION 'department_not_found:Department not found';
|
|
167
|
+
END IF;
|
|
168
|
+
|
|
169
|
+
IF v_dept.is_archived THEN
|
|
170
|
+
RAISE EXCEPTION 'department_archived:Cannot create tasks in an archived department';
|
|
171
|
+
END IF;
|
|
172
|
+
END IF;
|
|
173
|
+
|
|
174
|
+
-- 3. Resolve assignment fields
|
|
175
|
+
v_assigned_to := (p_payload->>'assigned_to_agent_key_id')::uuid;
|
|
176
|
+
v_requested_by := (p_payload->>'requested_by_agent_key_id')::uuid;
|
|
177
|
+
|
|
178
|
+
-- 4. Validate assigned agent exists and is active
|
|
179
|
+
IF v_assigned_to IS NOT NULL THEN
|
|
180
|
+
IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_assigned_to AND is_active = true) THEN
|
|
181
|
+
RAISE EXCEPTION 'invalid_assignee:Assigned agent key does not exist or is inactive';
|
|
182
|
+
END IF;
|
|
183
|
+
END IF;
|
|
184
|
+
|
|
185
|
+
-- 5. Insert task
|
|
186
|
+
INSERT INTO tasks (
|
|
187
|
+
project_id, department_id, priority, description, notes,
|
|
188
|
+
due_date, status, version,
|
|
189
|
+
created_by_type, created_by_id, updated_by_type, updated_by_id, source,
|
|
190
|
+
assigned_to_agent_key_id, requested_by_agent_key_id,
|
|
191
|
+
delegation_depth, assignment_chain
|
|
192
|
+
) VALUES (
|
|
193
|
+
(p_payload->>'project_id')::uuid,
|
|
194
|
+
v_dept_id,
|
|
195
|
+
COALESCE((p_payload->>'priority')::priority, 'medium'),
|
|
196
|
+
p_payload->>'description',
|
|
197
|
+
p_payload->>'notes',
|
|
198
|
+
(p_payload->>'due_date')::timestamptz,
|
|
199
|
+
COALESCE((p_payload->>'status')::status, 'todo'),
|
|
200
|
+
1,
|
|
201
|
+
(p_payload->>'created_by_type')::actor_type,
|
|
202
|
+
p_payload->>'created_by_id',
|
|
203
|
+
(p_payload->>'created_by_type')::actor_type,
|
|
204
|
+
p_payload->>'created_by_id',
|
|
205
|
+
(p_payload->>'source')::source,
|
|
206
|
+
v_assigned_to,
|
|
207
|
+
v_requested_by,
|
|
208
|
+
0,
|
|
209
|
+
'{}'
|
|
210
|
+
)
|
|
211
|
+
RETURNING * INTO v_task;
|
|
212
|
+
|
|
213
|
+
-- 6. Insert event_log
|
|
214
|
+
INSERT INTO event_log (
|
|
215
|
+
event_category, target_type, target_id, event_type,
|
|
216
|
+
actor_type, actor_id, actor_label, source
|
|
217
|
+
) VALUES (
|
|
218
|
+
'task', 'task', v_task.id, 'task.created',
|
|
219
|
+
(p_payload->>'actor_type')::actor_type,
|
|
220
|
+
p_payload->>'actor_id',
|
|
221
|
+
p_payload->>'actor_label',
|
|
222
|
+
(p_payload->>'source')::source
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
-- 7. Log assignment if task was assigned
|
|
226
|
+
IF v_assigned_to IS NOT NULL THEN
|
|
227
|
+
INSERT INTO event_log (
|
|
228
|
+
event_category, target_type, target_id, event_type,
|
|
229
|
+
field_name, new_value,
|
|
230
|
+
actor_type, actor_id, actor_label, source
|
|
231
|
+
) VALUES (
|
|
232
|
+
'task', 'task', v_task.id, 'task.assigned',
|
|
233
|
+
'assigned_to_agent_key_id', to_jsonb(v_assigned_to::text),
|
|
234
|
+
(p_payload->>'actor_type')::actor_type,
|
|
235
|
+
p_payload->>'actor_id',
|
|
236
|
+
p_payload->>'actor_label',
|
|
237
|
+
(p_payload->>'source')::source
|
|
238
|
+
);
|
|
239
|
+
END IF;
|
|
240
|
+
|
|
241
|
+
RETURN v_task;
|
|
242
|
+
END;
|
|
243
|
+
$$;
|
|
244
|
+
|
|
245
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
246
|
+
-- 8. Update update_task_with_events to track assignment changes
|
|
247
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
248
|
+
CREATE OR REPLACE FUNCTION update_task_with_events(p_payload jsonb)
|
|
249
|
+
RETURNS tasks
|
|
250
|
+
LANGUAGE plpgsql
|
|
251
|
+
SECURITY DEFINER
|
|
252
|
+
SET search_path = public
|
|
253
|
+
AS $$
|
|
254
|
+
DECLARE
|
|
255
|
+
v_old tasks%ROWTYPE;
|
|
256
|
+
v_new tasks%ROWTYPE;
|
|
257
|
+
v_task_id uuid;
|
|
258
|
+
v_version integer;
|
|
259
|
+
v_updates jsonb;
|
|
260
|
+
v_actor_type actor_type;
|
|
261
|
+
v_actor_id text;
|
|
262
|
+
v_actor_label text;
|
|
263
|
+
v_source source;
|
|
264
|
+
v_new_assignee uuid;
|
|
265
|
+
BEGIN
|
|
266
|
+
v_task_id := (p_payload->>'task_id')::uuid;
|
|
267
|
+
v_version := (p_payload->>'version')::integer;
|
|
268
|
+
v_updates := p_payload->'fields';
|
|
269
|
+
v_actor_type := (p_payload->>'actor_type')::actor_type;
|
|
270
|
+
v_actor_id := p_payload->>'actor_id';
|
|
271
|
+
v_actor_label := p_payload->>'actor_label';
|
|
272
|
+
v_source := (p_payload->>'source')::source;
|
|
273
|
+
|
|
274
|
+
-- 1. Fetch existing task
|
|
275
|
+
SELECT * INTO v_old FROM tasks WHERE id = v_task_id;
|
|
276
|
+
|
|
277
|
+
IF NOT FOUND THEN
|
|
278
|
+
RAISE EXCEPTION 'task_not_found:Task not found';
|
|
279
|
+
END IF;
|
|
280
|
+
|
|
281
|
+
-- 2. Version check
|
|
282
|
+
IF v_old.version <> v_version THEN
|
|
283
|
+
RAISE EXCEPTION 'version_conflict:Version conflict: expected %, current is %', v_version, v_old.version;
|
|
284
|
+
END IF;
|
|
285
|
+
|
|
286
|
+
-- 3. Delegation safety checks for reassignment
|
|
287
|
+
IF v_updates ? 'assigned_to_agent_key_id' THEN
|
|
288
|
+
v_new_assignee := (v_updates->>'assigned_to_agent_key_id')::uuid;
|
|
289
|
+
|
|
290
|
+
IF v_new_assignee IS NOT NULL THEN
|
|
291
|
+
-- Validate assignee exists and is active
|
|
292
|
+
IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_new_assignee AND is_active = true) THEN
|
|
293
|
+
RAISE EXCEPTION 'invalid_assignee:Assigned agent key does not exist or is inactive';
|
|
294
|
+
END IF;
|
|
295
|
+
|
|
296
|
+
-- Prevent circular delegation: new assignee must not be in assignment_chain
|
|
297
|
+
IF v_new_assignee = ANY(v_old.assignment_chain) THEN
|
|
298
|
+
RAISE EXCEPTION 'circular_delegation:Agent has already been in the delegation chain for this task';
|
|
299
|
+
END IF;
|
|
300
|
+
|
|
301
|
+
-- Enforce delegation depth limit
|
|
302
|
+
IF v_old.delegation_depth >= 3 THEN
|
|
303
|
+
RAISE EXCEPTION 'delegation_depth_exceeded:Maximum delegation depth (3) reached';
|
|
304
|
+
END IF;
|
|
305
|
+
END IF;
|
|
306
|
+
END IF;
|
|
307
|
+
|
|
308
|
+
-- 4. Update task (only provided fields)
|
|
309
|
+
UPDATE tasks SET
|
|
310
|
+
priority = COALESCE((v_updates->>'priority')::priority, priority),
|
|
311
|
+
description = COALESCE(v_updates->>'description', description),
|
|
312
|
+
notes = CASE WHEN v_updates ? 'notes' THEN v_updates->>'notes' ELSE notes END,
|
|
313
|
+
department_id = CASE WHEN v_updates ? 'department_id' THEN (v_updates->>'department_id')::uuid ELSE department_id END,
|
|
314
|
+
due_date = CASE WHEN v_updates ? 'due_date' THEN (v_updates->>'due_date')::timestamptz ELSE due_date END,
|
|
315
|
+
status = COALESCE((v_updates->>'status')::status, status),
|
|
316
|
+
assigned_to_agent_key_id = CASE
|
|
317
|
+
WHEN v_updates ? 'assigned_to_agent_key_id' THEN (v_updates->>'assigned_to_agent_key_id')::uuid
|
|
318
|
+
ELSE assigned_to_agent_key_id
|
|
319
|
+
END,
|
|
320
|
+
delegation_depth = CASE
|
|
321
|
+
WHEN v_updates ? 'assigned_to_agent_key_id' AND (v_updates->>'assigned_to_agent_key_id')::uuid IS NOT NULL
|
|
322
|
+
AND (v_updates->>'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id
|
|
323
|
+
THEN delegation_depth + 1
|
|
324
|
+
ELSE delegation_depth
|
|
325
|
+
END,
|
|
326
|
+
assignment_chain = CASE
|
|
327
|
+
WHEN v_updates ? 'assigned_to_agent_key_id' AND (v_updates->>'assigned_to_agent_key_id')::uuid IS NOT NULL
|
|
328
|
+
AND (v_updates->>'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id
|
|
329
|
+
AND assigned_to_agent_key_id IS NOT NULL
|
|
330
|
+
THEN array_append(assignment_chain, assigned_to_agent_key_id)
|
|
331
|
+
ELSE assignment_chain
|
|
332
|
+
END,
|
|
333
|
+
version = version + 1,
|
|
334
|
+
updated_at = now(),
|
|
335
|
+
updated_by_type = v_actor_type,
|
|
336
|
+
updated_by_id = v_actor_id,
|
|
337
|
+
source = v_source
|
|
338
|
+
WHERE id = v_task_id AND version = v_version
|
|
339
|
+
RETURNING * INTO v_new;
|
|
340
|
+
|
|
341
|
+
IF NOT FOUND THEN
|
|
342
|
+
RAISE EXCEPTION 'version_conflict:Task was modified concurrently';
|
|
343
|
+
END IF;
|
|
344
|
+
|
|
345
|
+
-- 5. Field-level diff using IS DISTINCT FROM, insert event_log rows
|
|
346
|
+
IF v_new.priority IS DISTINCT FROM v_old.priority THEN
|
|
347
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
348
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'priority', to_jsonb(v_old.priority::text), to_jsonb(v_new.priority::text), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
349
|
+
END IF;
|
|
350
|
+
|
|
351
|
+
IF v_new.description IS DISTINCT FROM v_old.description THEN
|
|
352
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
353
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'description', to_jsonb(v_old.description), to_jsonb(v_new.description), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
354
|
+
END IF;
|
|
355
|
+
|
|
356
|
+
IF v_new.notes IS DISTINCT FROM v_old.notes THEN
|
|
357
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
358
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'notes', to_jsonb(v_old.notes), to_jsonb(v_new.notes), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
359
|
+
END IF;
|
|
360
|
+
|
|
361
|
+
IF v_new.department_id IS DISTINCT FROM v_old.department_id THEN
|
|
362
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
363
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'department_id', to_jsonb(v_old.department_id), to_jsonb(v_new.department_id), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
364
|
+
END IF;
|
|
365
|
+
|
|
366
|
+
IF v_new.due_date IS DISTINCT FROM v_old.due_date THEN
|
|
367
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
368
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'due_date', to_jsonb(v_old.due_date), to_jsonb(v_new.due_date), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
369
|
+
END IF;
|
|
370
|
+
|
|
371
|
+
IF v_new.status IS DISTINCT FROM v_old.status THEN
|
|
372
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
373
|
+
VALUES ('task', 'task', v_task_id, 'task.updated', 'status', to_jsonb(v_old.status::text), to_jsonb(v_new.status::text), v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
374
|
+
END IF;
|
|
375
|
+
|
|
376
|
+
IF v_new.assigned_to_agent_key_id IS DISTINCT FROM v_old.assigned_to_agent_key_id THEN
|
|
377
|
+
INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
|
|
378
|
+
VALUES ('task', 'task', v_task_id, 'task.reassigned', 'assigned_to_agent_key_id',
|
|
379
|
+
to_jsonb(v_old.assigned_to_agent_key_id::text), to_jsonb(v_new.assigned_to_agent_key_id::text),
|
|
380
|
+
v_actor_type, v_actor_id, v_actor_label, v_source);
|
|
381
|
+
END IF;
|
|
382
|
+
|
|
383
|
+
RETURN v_new;
|
|
384
|
+
END;
|
|
385
|
+
$$;
|
|
386
|
+
|
|
387
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
388
|
+
-- 9. Update update_agent_permissions to support can_assign
|
|
389
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
390
|
+
CREATE OR REPLACE FUNCTION update_agent_permissions(p_key_id uuid, p_rows jsonb)
|
|
391
|
+
RETURNS void
|
|
392
|
+
LANGUAGE plpgsql
|
|
393
|
+
SECURITY DEFINER
|
|
394
|
+
SET search_path = public
|
|
395
|
+
AS $$
|
|
396
|
+
DECLARE
|
|
397
|
+
v_row jsonb;
|
|
398
|
+
BEGIN
|
|
399
|
+
-- Ensure caller is admin
|
|
400
|
+
IF NOT EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) THEN
|
|
401
|
+
RAISE EXCEPTION 'unauthorized:Only admins can update agent permissions';
|
|
402
|
+
END IF;
|
|
403
|
+
|
|
404
|
+
-- Delete existing permissions for this key
|
|
405
|
+
DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
|
|
406
|
+
|
|
407
|
+
-- Insert new permissions
|
|
408
|
+
FOR v_row IN SELECT * FROM jsonb_array_elements(p_rows) LOOP
|
|
409
|
+
INSERT INTO agent_permissions (
|
|
410
|
+
agent_key_id, project_id, department_id,
|
|
411
|
+
can_read, can_create, can_update, can_assign
|
|
412
|
+
) VALUES (
|
|
413
|
+
p_key_id,
|
|
414
|
+
(v_row->>'project_id')::uuid,
|
|
415
|
+
(v_row->>'department_id')::uuid,
|
|
416
|
+
COALESCE((v_row->>'can_read')::boolean, false),
|
|
417
|
+
COALESCE((v_row->>'can_create')::boolean, false),
|
|
418
|
+
COALESCE((v_row->>'can_update')::boolean, false),
|
|
419
|
+
COALESCE((v_row->>'can_assign')::boolean, false)
|
|
420
|
+
);
|
|
421
|
+
END LOOP;
|
|
422
|
+
END;
|
|
423
|
+
$$;
|