@team-semicolon/semicolony-cli 4.18.126 → 4.18.127
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/dist/bundle.js +900 -900
- package/migrations/224_mcp_catalog_compatibility.sql +19 -0
- package/migrations/225_meeting_action_history_preservation.sql +388 -0
- package/migrations/226_action_items_work_loop.sql +634 -0
- package/migrations/227_action_items_web_identity.sql +34 -0
- package/package.json +1 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
-- 226_action_items_work_loop.sql
|
|
2
|
+
-- Baseline: aa007fb57fea7758d3a1bee7ecfecbe3f2ff8d94. Runner owns transaction.
|
|
3
|
+
-- Additive ledger evolution. Existing execution state/history is never rewritten.
|
|
4
|
+
-- CHECK NOT VALID preserves legacy anomalies while guarding every new write.
|
|
5
|
+
-- Rollback: restore application version first; retain additive tables/history.
|
|
6
|
+
-- Canary: packages/cli/scripts/verify-action-items-work-loop.sql (ROLLBACK).
|
|
7
|
+
|
|
8
|
+
CREATE TABLE semicolony.action_external_contacts (
|
|
9
|
+
external_contact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
10
|
+
display_name text NOT NULL CHECK (length(btrim(display_name)) > 0),
|
|
11
|
+
source_ref text UNIQUE,
|
|
12
|
+
metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'),
|
|
13
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
14
|
+
);
|
|
15
|
+
ALTER TABLE semicolony.action_items
|
|
16
|
+
ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
|
17
|
+
ADD COLUMN external_contact_id uuid REFERENCES semicolony.action_external_contacts,
|
|
18
|
+
ADD COLUMN sponsor_member_id uuid REFERENCES semicolony.team_members,
|
|
19
|
+
ADD COLUMN waiting_on_external boolean NOT NULL DEFAULT false,
|
|
20
|
+
ADD COLUMN next_follow_up_at timestamptz,
|
|
21
|
+
ADD COLUMN last_confirmed_at timestamptz,
|
|
22
|
+
ADD CONSTRAINT action_items_metadata_object CHECK (jsonb_typeof(metadata) = 'object') NOT VALID,
|
|
23
|
+
ADD CONSTRAINT action_items_assignee_xor CHECK (
|
|
24
|
+
(assignee_kind = 'team-member' AND assignee_member_id IS NOT NULL AND assignee_agent_id IS NULL)
|
|
25
|
+
OR (assignee_kind = 'agent' AND assignee_agent_id IS NOT NULL AND assignee_member_id IS NULL)
|
|
26
|
+
OR (assignee_kind = 'unassigned' AND assignee_agent_id IS NULL AND assignee_member_id IS NULL)
|
|
27
|
+
) NOT VALID,
|
|
28
|
+
ADD CONSTRAINT action_items_external_sponsor CHECK (
|
|
29
|
+
external_contact_id IS NULL OR (sponsor_member_id IS NOT NULL AND assignee_kind = 'unassigned')
|
|
30
|
+
) NOT VALID;
|
|
31
|
+
CREATE INDEX action_items_page_idx ON semicolony.action_items(deadline, action_item_id);
|
|
32
|
+
CREATE INDEX action_items_sponsor_idx ON semicolony.action_items(sponsor_member_id, status);
|
|
33
|
+
CREATE FUNCTION semicolony.bump_action_item_version() RETURNS trigger
|
|
34
|
+
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
35
|
+
BEGIN NEW.version := OLD.version + 1; NEW.updated_at := now(); RETURN NEW; END $$;
|
|
36
|
+
CREATE TRIGGER action_item_version BEFORE UPDATE ON semicolony.action_items
|
|
37
|
+
FOR EACH ROW EXECUTE FUNCTION semicolony.bump_action_item_version();
|
|
38
|
+
|
|
39
|
+
CREATE TABLE semicolony.action_item_dependencies (
|
|
40
|
+
action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
|
|
41
|
+
depends_on_action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
PRIMARY KEY(action_item_id, depends_on_action_item_id),
|
|
44
|
+
CHECK(action_item_id <> depends_on_action_item_id)
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE semicolony.action_item_candidates (
|
|
47
|
+
candidate_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
48
|
+
source_kind text NOT NULL CHECK (source_kind IN ('meeting','feedback','incident','review','decision','manual','cron','slack','kb')),
|
|
49
|
+
source_ref text NOT NULL CHECK (length(btrim(source_ref)) > 0),
|
|
50
|
+
source_identity text NOT NULL CHECK (length(btrim(source_identity)) > 0),
|
|
51
|
+
proposal jsonb NOT NULL CHECK (jsonb_typeof(proposal) = 'object'),
|
|
52
|
+
reasons jsonb NOT NULL CHECK (jsonb_typeof(reasons) = 'array'),
|
|
53
|
+
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','rejected')),
|
|
54
|
+
meeting_id uuid REFERENCES semicolony.meetings,
|
|
55
|
+
assignee_member_id uuid REFERENCES semicolony.team_members,
|
|
56
|
+
sponsor_member_id uuid REFERENCES semicolony.team_members,
|
|
57
|
+
created_by_member_id uuid REFERENCES semicolony.team_members,
|
|
58
|
+
created_by_agent_id text REFERENCES semicolony.bot_status,
|
|
59
|
+
resolved_by_member_id uuid REFERENCES semicolony.team_members,
|
|
60
|
+
resolved_by_agent_id text REFERENCES semicolony.bot_status,
|
|
61
|
+
resolution_note text,
|
|
62
|
+
resolution_source_ref text,
|
|
63
|
+
action_item_id uuid REFERENCES semicolony.action_items,
|
|
64
|
+
version bigint NOT NULL DEFAULT 1,
|
|
65
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
66
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
67
|
+
UNIQUE(source_kind, source_ref, source_identity),
|
|
68
|
+
CHECK ((status = 'accepted') = (action_item_id IS NOT NULL))
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX action_item_candidates_page_idx ON semicolony.action_item_candidates(created_at,candidate_id);
|
|
71
|
+
CREATE TABLE semicolony.action_delegations (
|
|
72
|
+
delegation_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
73
|
+
token_hash text NOT NULL UNIQUE CHECK (token_hash ~ '^[a-f0-9]{64}$'),
|
|
74
|
+
allowed_actor_id text NOT NULL REFERENCES semicolony.bot_status,
|
|
75
|
+
requester_member_id uuid NOT NULL REFERENCES semicolony.team_members,
|
|
76
|
+
action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
|
|
77
|
+
expected_version bigint NOT NULL CHECK(expected_version > 0),
|
|
78
|
+
patch jsonb NOT NULL CHECK(jsonb_typeof(patch) = 'object'),
|
|
79
|
+
source_ref text NOT NULL CHECK(length(btrim(source_ref)) > 0),
|
|
80
|
+
expires_at timestamptz NOT NULL,
|
|
81
|
+
consumed_at timestamptz,
|
|
82
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
83
|
+
UNIQUE(source_ref, allowed_actor_id, action_item_id)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
-- Worker lanes have no restricted-read capability. A named meeting source
|
|
87
|
+
-- without a resolvable record is hidden, matching KB meeting projection policy.
|
|
88
|
+
CREATE FUNCTION semicolony.action_item_worker_visible(p_id uuid) RETURNS boolean
|
|
89
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
90
|
+
SELECT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.action_item_id=p_id
|
|
91
|
+
AND coalesce(a.metadata #>> '{classification,sensitivity}','internal') <> 'restricted'
|
|
92
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.meetings m WHERE
|
|
93
|
+
(m.meeting_id=a.meeting_id OR (a.source_kind='meeting' AND m.meeting_id::text=a.source_ref)) AND m.visibility IS DISTINCT FROM 'team')
|
|
94
|
+
AND (a.source_kind<>'meeting' OR EXISTS(SELECT 1 FROM semicolony.meetings m WHERE m.meeting_id=a.meeting_id OR m.meeting_id::text=a.source_ref)));
|
|
95
|
+
$$;
|
|
96
|
+
CREATE FUNCTION semicolony.action_candidate_source_visible(p_id uuid) RETURNS boolean
|
|
97
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
98
|
+
SELECT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE c.candidate_id=p_id
|
|
99
|
+
AND coalesce(c.proposal #>> '{metadata,classification,sensitivity}','internal') <> 'restricted'
|
|
100
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.meetings m WHERE
|
|
101
|
+
(m.meeting_id=c.meeting_id OR (c.source_kind='meeting' AND m.meeting_id::text=c.source_ref)) AND m.visibility IS DISTINCT FROM 'team')
|
|
102
|
+
AND (c.source_kind<>'meeting' OR EXISTS(SELECT 1 FROM semicolony.meetings m WHERE m.meeting_id=c.meeting_id OR m.meeting_id::text=c.source_ref)));
|
|
103
|
+
$$;
|
|
104
|
+
CREATE FUNCTION semicolony.action_contact_worker_visible(p_id text) RETURNS boolean
|
|
105
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
106
|
+
SELECT EXISTS(SELECT 1 FROM semicolony.action_external_contacts e WHERE e.external_contact_id::text=lower(btrim(p_id))
|
|
107
|
+
AND coalesce(e.metadata #>> '{classification,sensitivity}','internal') <> 'restricted'
|
|
108
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.external_contact_id=e.external_contact_id AND NOT semicolony.action_item_worker_visible(a.action_item_id))
|
|
109
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE lower(btrim(c.proposal->>'external_contact_id'))=lower(btrim(p_id)) AND NOT semicolony.action_candidate_source_visible(c.candidate_id)));
|
|
110
|
+
$$;
|
|
111
|
+
|
|
112
|
+
-- Separate source classification from linked-contact classification: contact visibility
|
|
113
|
+
-- reads only source visibility, so candidate -> contact never recurses back to itself.
|
|
114
|
+
CREATE FUNCTION semicolony.action_candidate_worker_visible(p_id uuid) RETURNS boolean
|
|
115
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
116
|
+
SELECT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE c.candidate_id=p_id
|
|
117
|
+
AND semicolony.action_candidate_source_visible(c.candidate_id)
|
|
118
|
+
-- Invalid or unknown contact facts remain visible pending proposals for repair.
|
|
119
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.action_external_contacts e WHERE e.external_contact_id::text=lower(btrim(c.proposal->>'external_contact_id')) AND NOT semicolony.action_contact_worker_visible(e.external_contact_id::text))
|
|
120
|
+
AND NOT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.action_item_id=c.action_item_id AND a.external_contact_id IS NOT NULL AND NOT semicolony.action_contact_worker_visible(a.external_contact_id::text)));
|
|
121
|
+
$$;
|
|
122
|
+
REVOKE ALL ON FUNCTION semicolony.action_candidate_source_visible(uuid) FROM PUBLIC;
|
|
123
|
+
|
|
124
|
+
-- Shared admission checks return missing/invalid facts instead of manufacturing them.
|
|
125
|
+
CREATE FUNCTION semicolony.action_candidate_reasons(p jsonb) RETURNS jsonb
|
|
126
|
+
LANGUAGE plpgsql STABLE SET search_path = pg_catalog AS $$
|
|
127
|
+
DECLARE r jsonb := '[]'; d date; owners integer;
|
|
128
|
+
BEGIN
|
|
129
|
+
IF jsonb_typeof(p) IS DISTINCT FROM 'object' THEN RETURN '["invalid_proposal"]'; END IF;
|
|
130
|
+
IF jsonb_typeof(p->'description') IS DISTINCT FROM 'string' THEN r := r || '"description_must_be_string"'::jsonb; END IF;
|
|
131
|
+
IF length(btrim(coalesce(p->>'description',''))) < 5 THEN r := r || '"description_required"'::jsonb; END IF;
|
|
132
|
+
IF NOT EXISTS(SELECT 1 FROM semicolony.ontology WHERE domain = p->>'project_domain') THEN r := r || '"project_required_or_unknown"'::jsonb; END IF;
|
|
133
|
+
BEGIN
|
|
134
|
+
IF coalesce(p->>'deadline','') !~ '^\d{4}-\d{2}-\d{2}$' THEN RAISE invalid_datetime_format; END IF;
|
|
135
|
+
d := (p->>'deadline')::date;
|
|
136
|
+
EXCEPTION WHEN invalid_datetime_format OR datetime_field_overflow THEN r := r || '"deadline_required_or_invalid"'::jsonb; END;
|
|
137
|
+
owners := (nullif(p->>'assignee_member','') IS NOT NULL)::integer
|
|
138
|
+
+ (nullif(p->>'assignee_agent','') IS NOT NULL)::integer
|
|
139
|
+
+ (coalesce(nullif(p->>'external_contact_id',''),nullif(p->>'external_contact','')) IS NOT NULL)::integer;
|
|
140
|
+
IF owners <> 1 THEN r := r || '"exactly_one_assignee_required"'::jsonb; END IF;
|
|
141
|
+
IF p->>'assignee_member' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=p->>'assignee_member' AND status='active') THEN r := r || '"assignee_member_unknown_or_inactive"'::jsonb; END IF;
|
|
142
|
+
IF p->>'assignee_agent' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p->>'assignee_agent' AND status IS DISTINCT FROM 'retired') THEN r := r || '"assignee_agent_unknown_or_retired"'::jsonb; END IF;
|
|
143
|
+
IF coalesce(p->>'external_contact_id',p->>'external_contact') IS NOT NULL THEN
|
|
144
|
+
IF p->>'external_contact_id' IS NOT NULL AND NOT semicolony.action_contact_worker_visible(p->>'external_contact_id') THEN r := r || '"external_contact_unknown"'::jsonb; END IF;
|
|
145
|
+
IF nullif(p->>'sponsor_member','') IS NULL THEN r := r || '"internal_sponsor_required"'::jsonb; END IF;
|
|
146
|
+
END IF;
|
|
147
|
+
IF p->>'sponsor_member' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=p->>'sponsor_member' AND status='active') THEN r := r || '"sponsor_unknown_or_inactive"'::jsonb; END IF;
|
|
148
|
+
IF p ? 'metadata' AND jsonb_typeof(p->'metadata') IS DISTINCT FROM 'object' THEN r := r || '"metadata_must_be_object"'::jsonb; END IF;
|
|
149
|
+
IF p ? 'priority' AND coalesce(p->>'priority','') NOT IN ('low','normal','high','urgent') THEN r := r || '"priority_invalid"'::jsonb; END IF;
|
|
150
|
+
RETURN r;
|
|
151
|
+
END $$;
|
|
152
|
+
|
|
153
|
+
CREATE FUNCTION semicolony.create_action_candidate(p_proposal jsonb, p_source_kind text,
|
|
154
|
+
p_source_ref text,p_source_identity text,p_caller_kind text,p_caller_member text,p_caller_agent text,
|
|
155
|
+
p_meeting_id uuid DEFAULT NULL) RETURNS uuid
|
|
156
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
|
|
157
|
+
DECLARE v_id uuid; v_member uuid;
|
|
158
|
+
BEGIN
|
|
159
|
+
IF jsonb_typeof(p_proposal) IS DISTINCT FROM 'object' THEN RAISE EXCEPTION 'invalid proposal' USING ERRCODE='22023'; END IF;
|
|
160
|
+
IF p_caller_kind='team-member' THEN
|
|
161
|
+
SELECT member_id INTO v_member FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
|
|
162
|
+
IF v_member IS NULL THEN RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001'; END IF;
|
|
163
|
+
ELSIF p_caller_kind='agent' THEN
|
|
164
|
+
IF NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_caller_agent AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001'; END IF;
|
|
165
|
+
ELSIF p_caller_kind IS DISTINCT FROM 'system' OR p_meeting_id IS NULL THEN
|
|
166
|
+
RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001';
|
|
167
|
+
END IF;
|
|
168
|
+
INSERT INTO semicolony.action_item_candidates(source_kind,source_ref,source_identity,proposal,reasons,meeting_id,assignee_member_id,sponsor_member_id,created_by_member_id,created_by_agent_id)
|
|
169
|
+
VALUES(p_source_kind,p_source_ref,p_source_identity,p_proposal,semicolony.action_candidate_reasons(p_proposal),p_meeting_id,(SELECT member_id FROM semicolony.team_members WHERE domain=p_proposal->>'assignee_member'),(SELECT member_id FROM semicolony.team_members WHERE domain=p_proposal->>'sponsor_member'),v_member,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END)
|
|
170
|
+
ON CONFLICT(source_kind,source_ref,source_identity) DO NOTHING RETURNING candidate_id INTO v_id;
|
|
171
|
+
IF v_id IS NULL THEN SELECT candidate_id INTO v_id FROM semicolony.action_item_candidates WHERE source_kind=p_source_kind AND source_ref=p_source_ref AND source_identity=p_source_identity; END IF;
|
|
172
|
+
RETURN v_id;
|
|
173
|
+
END $$;
|
|
174
|
+
|
|
175
|
+
CREATE FUNCTION semicolony.resolve_action_candidate(p_candidate_id uuid,p_decision text,
|
|
176
|
+
p_expected_version bigint,p_fields jsonb,p_note text,p_caller_kind text,p_caller_member text,p_caller_agent text)
|
|
177
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
|
|
178
|
+
DECLARE c semicolony.action_item_candidates%ROWTYPE; p jsonb; a uuid; m uuid; sponsor uuid; k text; contact uuid;
|
|
179
|
+
BEGIN
|
|
180
|
+
SELECT * INTO c FROM semicolony.action_item_candidates WHERE candidate_id=p_candidate_id FOR UPDATE;
|
|
181
|
+
IF NOT FOUND OR (p_caller_kind IS DISTINCT FROM 'system' AND NOT semicolony.action_candidate_worker_visible(p_candidate_id)) THEN RAISE EXCEPTION 'candidate_not_found' USING ERRCODE='AI002'; END IF;
|
|
182
|
+
SELECT member_id INTO m FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
|
|
183
|
+
-- Review belongs to a submitting human, proposed assignee/sponsor, or submitting/assigned agent.
|
|
184
|
+
IF NOT coalesce((p_caller_kind='system' AND c.meeting_id IS NOT NULL) OR (p_caller_kind='team-member' AND m IS NOT NULL AND
|
|
185
|
+
(c.created_by_member_id=m OR c.proposal->>'assignee_member'=p_caller_member OR c.proposal->>'sponsor_member'=p_caller_member OR
|
|
186
|
+
(c.created_by_member_id IS NULL AND c.created_by_agent_id IS NULL))) OR
|
|
187
|
+
(p_caller_kind='agent' AND p_caller_agent IS NOT NULL AND
|
|
188
|
+
(c.created_by_agent_id=p_caller_agent OR c.proposal->>'assignee_agent'=p_caller_agent)),false) THEN
|
|
189
|
+
RAISE EXCEPTION 'candidate_not_owned' USING ERRCODE='AI001'; END IF;
|
|
190
|
+
IF p_decision NOT IN ('accept','reject') OR p_decision IS NULL THEN RAISE EXCEPTION 'invalid decision' USING ERRCODE='22023'; END IF;
|
|
191
|
+
-- Retry of an already applied decision returns the original immutable result.
|
|
192
|
+
IF c.status <> 'pending' THEN
|
|
193
|
+
IF c.status = (CASE p_decision WHEN 'accept' THEN 'accepted' ELSE 'rejected' END) THEN RETURN c.action_item_id; END IF;
|
|
194
|
+
RAISE EXCEPTION 'candidate_already_resolved' USING ERRCODE='AI003';
|
|
195
|
+
END IF;
|
|
196
|
+
IF p_expected_version IS NOT NULL AND c.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
|
|
197
|
+
IF p_decision='reject' THEN
|
|
198
|
+
IF length(btrim(coalesce(p_note,'')))=0 THEN RAISE EXCEPTION 'rejection note required' USING ERRCODE='22023'; END IF;
|
|
199
|
+
ELSE
|
|
200
|
+
IF jsonb_typeof(p_fields) IS DISTINCT FROM 'object' OR NOT (p_fields ? 'project_domain' AND p_fields ? 'deadline') THEN RAISE EXCEPTION 'explicit project and deadline required' USING ERRCODE='22023'; END IF;
|
|
201
|
+
-- Assignee must be explicitly confirmed; a partial patch cannot inherit an old owner.
|
|
202
|
+
p := (c.proposal - ARRAY['assignee_member','assignee_agent','external_contact_id','external_contact','sponsor_member']) || p_fields;
|
|
203
|
+
IF semicolony.action_candidate_reasons(p) <> '[]'::jsonb THEN RAISE EXCEPTION 'candidate execution facts incomplete' USING ERRCODE='22023'; END IF;
|
|
204
|
+
k := CASE WHEN p->>'assignee_member' IS NOT NULL THEN 'team-member' WHEN p->>'assignee_agent' IS NOT NULL THEN 'agent' ELSE 'unassigned' END;
|
|
205
|
+
SELECT member_id INTO sponsor FROM semicolony.team_members WHERE domain=p->>'sponsor_member' AND status='active';
|
|
206
|
+
contact := (p->>'external_contact_id')::uuid;
|
|
207
|
+
IF contact IS NULL AND p->>'external_contact' IS NOT NULL THEN
|
|
208
|
+
INSERT INTO semicolony.action_external_contacts(display_name,source_ref,metadata) VALUES(p->>'external_contact','candidate:' || c.candidate_id::text,jsonb_build_object('classification',coalesce(p #> '{metadata,classification}','{}'::jsonb)))
|
|
209
|
+
ON CONFLICT(source_ref) DO UPDATE SET source_ref=EXCLUDED.source_ref RETURNING external_contact_id INTO contact;
|
|
210
|
+
END IF;
|
|
211
|
+
a := semicolony.create_action_item(p->>'description',p->>'project_domain',(p->>'deadline')::date,
|
|
212
|
+
k,p->>'assignee_member',p->>'assignee_agent',NULL,coalesce(p->>'priority','normal'),
|
|
213
|
+
c.source_kind,c.source_ref,p->>'related_url',p->>'external_contact',coalesce(p->'metadata','{}') || jsonb_build_object('candidate_id',c.candidate_id),
|
|
214
|
+
'system',NULL,NULL,NULL,NULL,NULL,NULL);
|
|
215
|
+
UPDATE semicolony.action_items SET external_contact_id=contact,
|
|
216
|
+
sponsor_member_id=sponsor,waiting_on_external=(contact IS NOT NULL),meeting_id=c.meeting_id WHERE action_item_id=a;
|
|
217
|
+
INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,actor_agent_id,payload)
|
|
218
|
+
VALUES(a,'noted',CASE p_caller_kind WHEN 'team-member' THEN 'human' WHEN 'agent' THEN 'agent' ELSE 'system' END,
|
|
219
|
+
m,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,
|
|
220
|
+
jsonb_build_object('candidate_id',c.candidate_id,'operation','candidate_accepted','note',p_note,'source_ref',c.source_ref));
|
|
221
|
+
END IF;
|
|
222
|
+
UPDATE semicolony.action_item_candidates SET status=CASE p_decision WHEN 'accept' THEN 'accepted' ELSE 'rejected' END,
|
|
223
|
+
action_item_id=a,resolution_note=p_note,resolved_by_member_id=m,
|
|
224
|
+
resolved_by_agent_id=CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,version=version+1,updated_at=now() WHERE candidate_id=c.candidate_id;
|
|
225
|
+
RETURN a;
|
|
226
|
+
END $$;
|
|
227
|
+
|
|
228
|
+
CREATE FUNCTION semicolony.validate_action_patch(p jsonb) RETURNS void
|
|
229
|
+
LANGUAGE plpgsql SET search_path=pg_catalog AS $$
|
|
230
|
+
BEGIN
|
|
231
|
+
IF jsonb_typeof(p) IS DISTINCT FROM 'object' OR p='{}'::jsonb OR EXISTS(
|
|
232
|
+
SELECT 1 FROM jsonb_object_keys(p) k WHERE k NOT IN ('status','deadline','note','metadata','description','priority','waiting_on_external','next_follow_up_at','last_confirmed_at','dependency_ids')) THEN
|
|
233
|
+
RAISE EXCEPTION 'invalid action patch' USING ERRCODE='22023'; END IF;
|
|
234
|
+
IF (p ? 'metadata' AND jsonb_typeof(p->'metadata') IS DISTINCT FROM 'object') OR
|
|
235
|
+
(p ? 'waiting_on_external' AND jsonb_typeof(p->'waiting_on_external') IS DISTINCT FROM 'boolean') OR
|
|
236
|
+
(p ? 'dependency_ids' AND jsonb_typeof(p->'dependency_ids') IS DISTINCT FROM 'array') OR
|
|
237
|
+
(p ? 'description' AND (jsonb_typeof(p->'description') IS DISTINCT FROM 'string' OR length(btrim(p->>'description'))<5)) OR
|
|
238
|
+
(p ? 'status' AND coalesce(p->>'status','') NOT IN ('open','in_progress','blocked','done','cancelled')) OR
|
|
239
|
+
(p ? 'priority' AND coalesce(p->>'priority','') NOT IN ('low','normal','high','urgent')) OR
|
|
240
|
+
(p ? 'deadline' AND coalesce(p->>'deadline','') !~ '^\d{4}-\d{2}-\d{2}$') THEN
|
|
241
|
+
RAISE EXCEPTION 'invalid action patch value' USING ERRCODE='22023'; END IF;
|
|
242
|
+
END $$;
|
|
243
|
+
|
|
244
|
+
CREATE FUNCTION semicolony.edit_action_item(p_action_item_id uuid,p_caller_kind text,
|
|
245
|
+
p_caller_member text,p_caller_agent text,p_expected_version bigint,p_patch jsonb,p_delegation_token_hash text DEFAULT NULL)
|
|
246
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
247
|
+
DECLARE a semicolony.action_items%ROWTYPE; m uuid; d semicolony.action_delegations%ROWTYPE; v_source text; dep uuid;
|
|
248
|
+
BEGIN
|
|
249
|
+
PERFORM semicolony.validate_action_patch(p_patch);
|
|
250
|
+
-- Delegation lock first consistently, then action lock. Any later failure rolls consumption back.
|
|
251
|
+
IF p_delegation_token_hash IS NOT NULL THEN
|
|
252
|
+
SELECT * INTO d FROM semicolony.action_delegations WHERE token_hash=p_delegation_token_hash FOR UPDATE;
|
|
253
|
+
IF NOT FOUND OR p_caller_kind IS DISTINCT FROM 'agent' OR d.allowed_actor_id IS DISTINCT FROM p_caller_agent
|
|
254
|
+
OR d.action_item_id IS DISTINCT FROM p_action_item_id OR d.expected_version IS DISTINCT FROM p_expected_version
|
|
255
|
+
OR d.patch IS DISTINCT FROM p_patch OR d.expires_at<=clock_timestamp() OR d.consumed_at IS NOT NULL THEN
|
|
256
|
+
RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
|
|
257
|
+
m := d.requester_member_id; v_source := d.source_ref;
|
|
258
|
+
ELSE
|
|
259
|
+
SELECT member_id INTO m FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
|
|
260
|
+
END IF;
|
|
261
|
+
IF p_patch ? 'dependency_ids' THEN PERFORM pg_advisory_xact_lock(hashtextextended('action-dependencies',0)); END IF;
|
|
262
|
+
SELECT * INTO a FROM semicolony.action_items WHERE action_item_id=p_action_item_id FOR UPDATE;
|
|
263
|
+
IF NOT FOUND OR NOT semicolony.action_item_worker_visible(p_action_item_id) THEN RAISE EXCEPTION 'action_item_not_found' USING ERRCODE='AI002'; END IF;
|
|
264
|
+
IF NOT coalesce(((p_caller_kind='team-member' OR d.delegation_id IS NOT NULL) AND m IS NOT NULL AND
|
|
265
|
+
(a.assignee_member_id=m OR a.sponsor_member_id=m)) OR
|
|
266
|
+
(d.delegation_id IS NULL AND p_caller_kind='agent' AND p_caller_agent IS NOT NULL AND a.assignee_agent_id=p_caller_agent),false) THEN
|
|
267
|
+
RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
|
|
268
|
+
IF d.delegation_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE member_id=m AND status='active') THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
|
|
269
|
+
IF p_caller_kind='agent' AND NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_caller_agent AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
|
|
270
|
+
IF p_expected_version IS NOT NULL AND a.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
|
|
271
|
+
IF p_patch ? 'dependency_ids' THEN
|
|
272
|
+
-- Serialize graph mutations to prevent concurrent edge insertions forming a cycle.
|
|
273
|
+
DELETE FROM semicolony.action_item_dependencies WHERE action_item_id=p_action_item_id;
|
|
274
|
+
FOR dep IN SELECT value::uuid FROM jsonb_array_elements_text(p_patch->'dependency_ids') LOOP
|
|
275
|
+
IF NOT semicolony.action_item_worker_visible(dep) THEN RAISE EXCEPTION 'dependency_not_found' USING ERRCODE='AI002'; END IF;
|
|
276
|
+
IF dep=p_action_item_id OR EXISTS(WITH RECURSIVE chain(id) AS (
|
|
277
|
+
SELECT dep UNION SELECT e.depends_on_action_item_id FROM semicolony.action_item_dependencies e JOIN chain c ON e.action_item_id=c.id
|
|
278
|
+
) SELECT 1 FROM chain WHERE id=p_action_item_id) THEN RAISE EXCEPTION 'dependency_cycle' USING ERRCODE='22023'; END IF;
|
|
279
|
+
INSERT INTO semicolony.action_item_dependencies VALUES(p_action_item_id,dep,now()) ON CONFLICT DO NOTHING;
|
|
280
|
+
END LOOP;
|
|
281
|
+
END IF;
|
|
282
|
+
UPDATE semicolony.action_items SET
|
|
283
|
+
description=coalesce(p_patch->>'description',description),priority=coalesce(p_patch->>'priority',priority),
|
|
284
|
+
status=coalesce(p_patch->>'status',status),deadline=coalesce((p_patch->>'deadline')::date,deadline),
|
|
285
|
+
completed_at=CASE WHEN p_patch->>'status'='done' AND status<>'done' THEN now()
|
|
286
|
+
WHEN p_patch ? 'status' AND p_patch->>'status'<>'done' THEN NULL ELSE completed_at END,
|
|
287
|
+
metadata=CASE WHEN p_patch ? 'metadata' THEN metadata || (p_patch->'metadata') ELSE metadata END,
|
|
288
|
+
waiting_on_external=coalesce((p_patch->>'waiting_on_external')::boolean,waiting_on_external),
|
|
289
|
+
next_follow_up_at=CASE WHEN p_patch ? 'next_follow_up_at' THEN (p_patch->>'next_follow_up_at')::timestamptz ELSE next_follow_up_at END,
|
|
290
|
+
last_confirmed_at=CASE WHEN p_patch ? 'last_confirmed_at' THEN (p_patch->>'last_confirmed_at')::timestamptz ELSE last_confirmed_at END
|
|
291
|
+
WHERE action_item_id=p_action_item_id;
|
|
292
|
+
INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,actor_agent_id,payload)
|
|
293
|
+
VALUES(p_action_item_id,CASE WHEN p_patch->>'status'='done' THEN 'closed' WHEN p_patch ? 'status' THEN 'status_changed' WHEN p_patch ? 'deadline' THEN 'deadline_changed' ELSE 'noted' END,
|
|
294
|
+
CASE WHEN p_caller_kind='agent' THEN 'agent' ELSE 'human' END,m,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,
|
|
295
|
+
jsonb_build_object('patch',p_patch,'previous_version',a.version,'version',a.version+1,'delegation_id',d.delegation_id,'source_ref',v_source));
|
|
296
|
+
IF d.delegation_id IS NOT NULL THEN
|
|
297
|
+
IF d.expires_at<=clock_timestamp() THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
|
|
298
|
+
UPDATE semicolony.action_delegations SET consumed_at=clock_timestamp() WHERE delegation_id=d.delegation_id;
|
|
299
|
+
END IF;
|
|
300
|
+
RETURN p_action_item_id;
|
|
301
|
+
END $$;
|
|
302
|
+
|
|
303
|
+
-- Legacy worker callers keep their positional signature and absence semantics.
|
|
304
|
+
CREATE OR REPLACE FUNCTION semicolony.update_action_item(p_action_item_id uuid,p_caller_kind text,p_caller_member text,p_caller_agent text,p_status text,p_deadline date,p_note text,p_metadata jsonb)
|
|
305
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
306
|
+
BEGIN RETURN semicolony.edit_action_item(p_action_item_id,p_caller_kind,p_caller_member,p_caller_agent,NULL,
|
|
307
|
+
coalesce((SELECT jsonb_object_agg(key,value) FROM jsonb_each(jsonb_build_object('status',p_status,'deadline',p_deadline,'note',p_note,'metadata',p_metadata)) WHERE value<>'null'::jsonb),'{}'::jsonb) || CASE WHEN p_status IS NULL AND p_deadline IS NULL AND p_note IS NULL AND p_metadata IS NULL THEN '{"note":null}'::jsonb ELSE '{}'::jsonb END,NULL); END $$;
|
|
308
|
+
|
|
309
|
+
CREATE FUNCTION semicolony.action_slack_member(p_slack_user_id text) RETURNS text
|
|
310
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
311
|
+
DECLARE d text;
|
|
312
|
+
BEGIN
|
|
313
|
+
IF (SELECT count(*) FROM semicolony.team_members WHERE slack_user_id=p_slack_user_id AND status='active')<>1 THEN RAISE EXCEPTION 'slack_member_not_found' USING ERRCODE='AI001'; END IF;
|
|
314
|
+
SELECT domain INTO d FROM semicolony.team_members WHERE slack_user_id=p_slack_user_id AND status='active'; RETURN d;
|
|
315
|
+
END $$;
|
|
316
|
+
CREATE FUNCTION semicolony.issue_action_delegation(p_requester_slack_user_id text,p_actor_id text,
|
|
317
|
+
p_action_item_id uuid,p_expected_version bigint,p_patch jsonb,p_source_ref text,p_token_hash text,p_expires_at timestamptz)
|
|
318
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
319
|
+
DECLARE m uuid; a semicolony.action_items%ROWTYPE; id uuid;
|
|
320
|
+
BEGIN
|
|
321
|
+
PERFORM semicolony.validate_action_patch(p_patch);
|
|
322
|
+
SELECT member_id INTO m FROM semicolony.team_members WHERE domain=semicolony.action_slack_member(p_requester_slack_user_id);
|
|
323
|
+
SELECT * INTO a FROM semicolony.action_items WHERE action_item_id=p_action_item_id FOR UPDATE;
|
|
324
|
+
IF NOT FOUND OR NOT semicolony.action_item_worker_visible(p_action_item_id) THEN RAISE EXCEPTION 'action_item_not_found' USING ERRCODE='AI002'; END IF;
|
|
325
|
+
IF NOT coalesce(a.assignee_member_id=m OR a.sponsor_member_id=m,false) THEN RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
|
|
326
|
+
IF p_expected_version IS NULL OR a.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
|
|
327
|
+
IF p_expires_at IS NULL OR p_expires_at<=clock_timestamp() OR p_expires_at>clock_timestamp()+interval '15 minutes'
|
|
328
|
+
OR NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_actor_id AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
|
|
329
|
+
INSERT INTO semicolony.action_delegations(token_hash,allowed_actor_id,requester_member_id,action_item_id,expected_version,patch,source_ref,expires_at)
|
|
330
|
+
VALUES(p_token_hash,p_actor_id,m,p_action_item_id,p_expected_version,p_patch,p_source_ref,p_expires_at) RETURNING delegation_id INTO id;
|
|
331
|
+
RETURN id;
|
|
332
|
+
END $$;
|
|
333
|
+
CREATE FUNCTION semicolony.apply_slack_action_update(p_requester_slack_user_id text,p_action_item_id uuid,
|
|
334
|
+
p_expected_version bigint,p_patch jsonb,p_source_ref text) RETURNS uuid
|
|
335
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
336
|
+
DECLARE id uuid;
|
|
337
|
+
BEGIN
|
|
338
|
+
IF p_expected_version IS NULL OR length(btrim(coalesce(p_source_ref,'')))=0 THEN RAISE EXCEPTION 'version and Slack source required' USING ERRCODE='22023'; END IF;
|
|
339
|
+
id:=semicolony.edit_action_item(p_action_item_id,'team-member',semicolony.action_slack_member(p_requester_slack_user_id),NULL,p_expected_version,p_patch);
|
|
340
|
+
INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,payload)
|
|
341
|
+
SELECT id,'noted','human',member_id,jsonb_build_object('source_ref',p_source_ref,'source_kind','slack') FROM semicolony.team_members WHERE domain=semicolony.action_slack_member(p_requester_slack_user_id);
|
|
342
|
+
RETURN id;
|
|
343
|
+
END $$;
|
|
344
|
+
|
|
345
|
+
-- Preserve migration 225 meeting identity and KB/provenance behavior. Each item
|
|
346
|
+
-- has its own admission savepoint; a malformed sibling becomes a durable candidate.
|
|
347
|
+
CREATE OR REPLACE FUNCTION semicolony.record_meeting(
|
|
348
|
+
p_title text,
|
|
349
|
+
p_meeting_date date,
|
|
350
|
+
p_meeting_type text,
|
|
351
|
+
p_adhoc_subtype text,
|
|
352
|
+
p_visibility text,
|
|
353
|
+
p_target_domain text,
|
|
354
|
+
p_attendees jsonb,
|
|
355
|
+
p_record_body text,
|
|
356
|
+
p_transcript text,
|
|
357
|
+
p_action_items jsonb,
|
|
358
|
+
p_record_source text,
|
|
359
|
+
p_worker_id text,
|
|
360
|
+
p_device_id text,
|
|
361
|
+
p_discussion_url text DEFAULT NULL,
|
|
362
|
+
p_discussion_number integer DEFAULT NULL
|
|
363
|
+
)
|
|
364
|
+
RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
|
|
365
|
+
LANGUAGE plpgsql
|
|
366
|
+
SECURITY DEFINER
|
|
367
|
+
SET search_path = pg_catalog
|
|
368
|
+
AS $function$
|
|
369
|
+
DECLARE
|
|
370
|
+
v_meeting_id uuid;
|
|
371
|
+
v_kb_id bigint;
|
|
372
|
+
v_existing uuid;
|
|
373
|
+
v_norm text;
|
|
374
|
+
v_sensitivity text;
|
|
375
|
+
v_sub_key text;
|
|
376
|
+
v_item jsonb;
|
|
377
|
+
v_item_id uuid;
|
|
378
|
+
v_preserved jsonb;
|
|
379
|
+
v_description text;
|
|
380
|
+
v_priority text;
|
|
381
|
+
v_project text;
|
|
382
|
+
v_candidate text;
|
|
383
|
+
v_assignee_kind text;
|
|
384
|
+
v_assignee_member text;
|
|
385
|
+
v_assignee_agent text;
|
|
386
|
+
v_external_contact text;
|
|
387
|
+
v_deadline date;
|
|
388
|
+
v_proposal jsonb;
|
|
389
|
+
v_reasons jsonb;
|
|
390
|
+
v_candidate_id uuid;
|
|
391
|
+
v_identity text;
|
|
392
|
+
v_candidate_ids uuid[] := '{}';
|
|
393
|
+
v_existing_candidate semicolony.action_item_candidates%ROWTYPE;
|
|
394
|
+
BEGIN
|
|
395
|
+
IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
|
|
396
|
+
RAISE EXCEPTION 'title must be at least 3 characters';
|
|
397
|
+
END IF;
|
|
398
|
+
IF p_meeting_date IS NULL THEN
|
|
399
|
+
RAISE EXCEPTION 'meeting_date is required';
|
|
400
|
+
END IF;
|
|
401
|
+
IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
|
|
402
|
+
RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
|
|
403
|
+
END IF;
|
|
404
|
+
IF p_record_source IS NULL
|
|
405
|
+
OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
|
|
406
|
+
RAISE EXCEPTION 'invalid record_source %', p_record_source;
|
|
407
|
+
END IF;
|
|
408
|
+
IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
|
|
409
|
+
RAISE EXCEPTION 'record_body is required';
|
|
410
|
+
END IF;
|
|
411
|
+
|
|
412
|
+
v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
|
|
413
|
+
v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
|
|
414
|
+
|
|
415
|
+
-- Serialize all writers for one normalized meeting, including first insert.
|
|
416
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(
|
|
417
|
+
'meeting:' || p_meeting_date::text || ':' || v_norm, 0));
|
|
418
|
+
|
|
419
|
+
SELECT m.meeting_id INTO v_existing
|
|
420
|
+
FROM semicolony.meetings m
|
|
421
|
+
WHERE m.meeting_date = p_meeting_date
|
|
422
|
+
AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
|
|
423
|
+
|
|
424
|
+
-- Discussion provenance from 202_meeting_record_discussion_provenance,
|
|
425
|
+
-- unchanged: COALESCE on update so a re-record that omits the link does not
|
|
426
|
+
-- erase one an earlier submission established, and both columns written on
|
|
427
|
+
-- insert.
|
|
428
|
+
IF v_existing IS NOT NULL THEN
|
|
429
|
+
UPDATE semicolony.meetings m
|
|
430
|
+
SET record_body = COALESCE(p_record_body, m.record_body),
|
|
431
|
+
mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
|
|
432
|
+
visibility = p_visibility,
|
|
433
|
+
record_source = p_record_source,
|
|
434
|
+
target_domain = COALESCE(p_target_domain, m.target_domain),
|
|
435
|
+
attendees = COALESCE(p_attendees, m.attendees),
|
|
436
|
+
discussion_url = COALESCE(p_discussion_url, m.discussion_url),
|
|
437
|
+
discussion_number = COALESCE(p_discussion_number, m.discussion_number),
|
|
438
|
+
updated_at = now()
|
|
439
|
+
WHERE m.meeting_id = v_existing;
|
|
440
|
+
v_meeting_id := v_existing;
|
|
441
|
+
ELSE
|
|
442
|
+
INSERT INTO semicolony.meetings (
|
|
443
|
+
meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
|
|
444
|
+
visibility, record_body, mapped_transcript, record_source, target_domain,
|
|
445
|
+
transcription_status, speaker_map, discussion_url, discussion_number,
|
|
446
|
+
created_at, updated_at
|
|
447
|
+
) VALUES (
|
|
448
|
+
gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
|
|
449
|
+
COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
|
|
450
|
+
p_transcript, p_record_source, p_target_domain,
|
|
451
|
+
'completed', '{}'::jsonb, p_discussion_url, p_discussion_number, now(), now()
|
|
452
|
+
) RETURNING meetings.meeting_id INTO v_meeting_id;
|
|
453
|
+
END IF;
|
|
454
|
+
|
|
455
|
+
v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
|
|
456
|
+
|
|
457
|
+
INSERT INTO semicolony.knowledge_base (
|
|
458
|
+
domain, key, sub_key, content, metadata, created_by, created_at, updated_at
|
|
459
|
+
) VALUES (
|
|
460
|
+
COALESCE(p_target_domain, 'semicolon'),
|
|
461
|
+
'meeting',
|
|
462
|
+
v_sub_key,
|
|
463
|
+
p_record_body,
|
|
464
|
+
jsonb_build_object(
|
|
465
|
+
'classification', jsonb_build_object('sensitivity', v_sensitivity),
|
|
466
|
+
'meeting_id', v_meeting_id,
|
|
467
|
+
'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
|
|
468
|
+
'record_source', p_record_source
|
|
469
|
+
),
|
|
470
|
+
COALESCE(p_worker_id, 'meeting-worker'),
|
|
471
|
+
now(), now()
|
|
472
|
+
)
|
|
473
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE
|
|
474
|
+
SET content = EXCLUDED.content,
|
|
475
|
+
metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
|
|
476
|
+
updated_at = now()
|
|
477
|
+
RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
v_preserved := '[]';
|
|
481
|
+
IF jsonb_typeof(p_action_items)='array' THEN
|
|
482
|
+
FOR v_item IN SELECT value FROM jsonb_array_elements(p_action_items) LOOP
|
|
483
|
+
v_description := CASE WHEN jsonb_typeof(v_item->'description')='string' THEN btrim(v_item->>'description') ELSE '' END;
|
|
484
|
+
v_identity := coalesce(nullif(v_item->>'source_identity',''),encode(sha256(convert_to(
|
|
485
|
+
CASE WHEN v_description<>'' THEN lower(regexp_replace(v_description,'\s+',' ','g')) ELSE v_item::text END,'UTF8')),'hex'));
|
|
486
|
+
v_item_id := NULL;
|
|
487
|
+
SELECT a.action_item_id INTO v_item_id FROM semicolony.action_items a
|
|
488
|
+
WHERE a.meeting_id=v_meeting_id AND lower(regexp_replace(btrim(a.description),'\s+',' ','g'))=
|
|
489
|
+
lower(regexp_replace(v_description,'\s+',' ','g')) ORDER BY a.created_at,a.action_item_id LIMIT 1;
|
|
490
|
+
IF v_item_id IS NOT NULL THEN
|
|
491
|
+
v_preserved := v_preserved || jsonb_build_array(jsonb_build_object('action_item_id',v_item_id,'submitted',v_item,'reason','existing_followup_preserved'));
|
|
492
|
+
CONTINUE;
|
|
493
|
+
END IF;
|
|
494
|
+
v_project := coalesce(nullif(v_item->>'project_domain',''),nullif(v_item->>'target',''),nullif(p_target_domain,''));
|
|
495
|
+
v_assignee_member := nullif(v_item->>'assignee_member','');
|
|
496
|
+
v_assignee_agent := nullif(v_item->>'assignee_agent','');
|
|
497
|
+
v_candidate := coalesce(nullif(v_item->>'assignee',''),nullif(v_item->>'owner',''));
|
|
498
|
+
IF v_assignee_member IS NULL AND v_assignee_agent IS NULL AND v_candidate IS NOT NULL THEN
|
|
499
|
+
IF EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=v_candidate AND status='active') THEN v_assignee_member:=v_candidate;
|
|
500
|
+
ELSIF EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=v_candidate AND status IS DISTINCT FROM 'retired') THEN v_assignee_agent:=v_candidate;
|
|
501
|
+
END IF;
|
|
502
|
+
END IF;
|
|
503
|
+
v_proposal := jsonb_strip_nulls(jsonb_build_object('description',v_item->'description','project_domain',v_project,
|
|
504
|
+
'deadline',v_item->>'deadline','assignee_member',v_assignee_member,'assignee_agent',v_assignee_agent,
|
|
505
|
+
'external_contact_id',v_item->>'external_contact_id','sponsor_member',v_item->>'sponsor_member',
|
|
506
|
+
'external_contact',CASE WHEN v_assignee_member IS NULL AND v_assignee_agent IS NULL THEN coalesce(nullif(v_item->>'external_contact',''),v_candidate) ELSE v_item->>'external_contact' END,
|
|
507
|
+
'priority',coalesce(v_item->>'priority','normal'),'metadata',jsonb_build_object('meeting_id',v_meeting_id,'classification',jsonb_build_object('sensitivity',v_sensitivity),'raw_item',v_item)));
|
|
508
|
+
v_candidate_id:=semicolony.create_action_candidate(v_proposal,'meeting',v_meeting_id::text,v_identity,'system',NULL,NULL,v_meeting_id);
|
|
509
|
+
v_candidate_ids:=array_append(v_candidate_ids,v_candidate_id);
|
|
510
|
+
SELECT * INTO v_existing_candidate FROM semicolony.action_item_candidates WHERE candidate_id=v_candidate_id;
|
|
511
|
+
IF v_existing_candidate.status<>'pending' OR v_existing_candidate.proposal IS DISTINCT FROM v_proposal THEN CONTINUE; END IF;
|
|
512
|
+
v_reasons:=semicolony.action_candidate_reasons(v_proposal);
|
|
513
|
+
IF v_reasons='[]'::jsonb THEN
|
|
514
|
+
BEGIN
|
|
515
|
+
PERFORM semicolony.resolve_action_candidate(v_candidate_id,'accept',v_existing_candidate.version,v_proposal,
|
|
516
|
+
'Complete explicit meeting commitment','system',NULL,NULL);
|
|
517
|
+
EXCEPTION WHEN OTHERS THEN
|
|
518
|
+
-- Retain only SQLSTATE, never a raw database error/credential string.
|
|
519
|
+
UPDATE semicolony.action_item_candidates SET reasons=jsonb_build_array('admission_failed',SQLSTATE) WHERE candidate_id=v_candidate_id;
|
|
520
|
+
END;
|
|
521
|
+
END IF;
|
|
522
|
+
END LOOP;
|
|
523
|
+
UPDATE semicolony.meetings SET metadata=coalesce(metadata,'{}') || jsonb_build_object('meeting_import',
|
|
524
|
+
jsonb_build_object('candidate_ids',to_jsonb(v_candidate_ids),'preserved_action_items',v_preserved,
|
|
525
|
+
'preserved_count',jsonb_array_length(v_preserved),'submitted_count',jsonb_array_length(p_action_items),'recorded_at',now()))
|
|
526
|
+
WHERE meetings.meeting_id=v_meeting_id;
|
|
527
|
+
END IF;
|
|
528
|
+
RETURN QUERY SELECT v_meeting_id,v_kb_id,
|
|
529
|
+
ARRAY(SELECT a.action_item_id FROM semicolony.action_items a WHERE a.meeting_id=v_meeting_id ORDER BY a.created_at,a.action_item_id),
|
|
530
|
+
v_existing IS NOT NULL;
|
|
531
|
+
END;
|
|
532
|
+
$function$;
|
|
533
|
+
|
|
534
|
+
-- Gateway cannot mint delegation. The hive service cannot call a generic editor.
|
|
535
|
+
DO $$ BEGIN
|
|
536
|
+
IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='semo_ops_action_loop') THEN
|
|
537
|
+
CREATE ROLE semo_ops_action_loop NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
|
538
|
+
END IF;
|
|
539
|
+
END $$;
|
|
540
|
+
-- The runner retargets qualified identifiers, not bare GRANT schema names.
|
|
541
|
+
-- Resolve the namespace through the retargeted ledger relation itself.
|
|
542
|
+
DO $$ DECLARE target_schema text;
|
|
543
|
+
BEGIN
|
|
544
|
+
SELECT n.nspname INTO target_schema FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
|
545
|
+
WHERE c.oid='semicolony.action_items'::regclass;
|
|
546
|
+
EXECUTE format('GRANT USAGE ON SCHEMA %I TO semo_ops_action_loop',target_schema);
|
|
547
|
+
END $$;
|
|
548
|
+
GRANT SELECT ON semicolony.action_items,semicolony.action_item_events,semicolony.team_members,
|
|
549
|
+
semicolony.meetings,semicolony.action_item_candidates,semicolony.action_external_contacts,
|
|
550
|
+
semicolony.action_item_dependencies TO semo_ops_action_loop;
|
|
551
|
+
GRANT SELECT ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies TO sc_app;
|
|
552
|
+
GRANT ALL ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies,semicolony.action_delegations TO app;
|
|
553
|
+
DO $$ BEGIN
|
|
554
|
+
IF EXISTS(SELECT 1 FROM pg_roles WHERE rolname='sc_fleet_board') THEN
|
|
555
|
+
GRANT SELECT ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies TO sc_fleet_board;
|
|
556
|
+
END IF;
|
|
557
|
+
END $$;
|
|
558
|
+
REVOKE ALL ON FUNCTION semicolony.bump_action_item_version(),semicolony.action_candidate_reasons(jsonb),
|
|
559
|
+
semicolony.validate_action_patch(jsonb),semicolony.action_slack_member(text),
|
|
560
|
+
semicolony.create_action_candidate(jsonb,text,text,text,text,text,text,uuid),
|
|
561
|
+
semicolony.resolve_action_candidate(uuid,text,bigint,jsonb,text,text,text,text),
|
|
562
|
+
semicolony.edit_action_item(uuid,text,text,text,bigint,jsonb,text),
|
|
563
|
+
semicolony.issue_action_delegation(text,text,uuid,bigint,jsonb,text,text,timestamptz),
|
|
564
|
+
semicolony.apply_slack_action_update(text,uuid,bigint,jsonb,text) FROM PUBLIC;
|
|
565
|
+
GRANT EXECUTE ON FUNCTION semicolony.create_action_candidate(jsonb,text,text,text,text,text,text,uuid),
|
|
566
|
+
semicolony.resolve_action_candidate(uuid,text,bigint,jsonb,text,text,text,text),
|
|
567
|
+
semicolony.edit_action_item(uuid,text,text,text,bigint,jsonb,text) TO sc_app,app;
|
|
568
|
+
GRANT EXECUTE ON FUNCTION semicolony.issue_action_delegation(text,text,uuid,bigint,jsonb,text,text,timestamptz),
|
|
569
|
+
semicolony.apply_slack_action_update(text,uuid,bigint,jsonb,text) TO semo_ops_action_loop,app;
|
|
570
|
+
|
|
571
|
+
CREATE FUNCTION semicolony.create_action_item_v2(
|
|
572
|
+
p_description text,
|
|
573
|
+
p_project_domain text,
|
|
574
|
+
p_deadline date,
|
|
575
|
+
p_assignee_kind text,
|
|
576
|
+
p_assignee_member text,
|
|
577
|
+
p_assignee_agent text,
|
|
578
|
+
p_assignee_device text,
|
|
579
|
+
p_priority text,
|
|
580
|
+
p_source_kind text,
|
|
581
|
+
p_source_ref text,
|
|
582
|
+
p_related_url text,
|
|
583
|
+
p_external_contact text,
|
|
584
|
+
p_metadata jsonb,
|
|
585
|
+
p_origin_surface text,
|
|
586
|
+
p_origin_actor_kind text,
|
|
587
|
+
p_origin_member text,
|
|
588
|
+
p_origin_agent text,
|
|
589
|
+
p_origin_device text,
|
|
590
|
+
p_origin_harness text,
|
|
591
|
+
p_origin_session_key text,
|
|
592
|
+
p_external_contact_id uuid,
|
|
593
|
+
p_sponsor_member text
|
|
594
|
+
) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
595
|
+
DECLARE id uuid; sponsor uuid; contact uuid := p_external_contact_id;
|
|
596
|
+
BEGIN
|
|
597
|
+
IF contact IS NOT NULL AND NOT semicolony.action_contact_worker_visible(contact::text) THEN RAISE EXCEPTION 'external_contact_not_found' USING ERRCODE='AI002'; END IF;
|
|
598
|
+
IF p_sponsor_member IS NOT NULL THEN
|
|
599
|
+
SELECT member_id INTO sponsor FROM semicolony.team_members WHERE domain=p_sponsor_member AND status='active';
|
|
600
|
+
IF sponsor IS NULL THEN RAISE EXCEPTION 'unknown or inactive sponsor' USING ERRCODE='22023'; END IF;
|
|
601
|
+
END IF;
|
|
602
|
+
IF p_external_contact_id IS NOT NULL AND (sponsor IS NULL OR p_assignee_kind<>'unassigned') THEN RAISE EXCEPTION 'external work requires internal sponsor' USING ERRCODE='22023'; END IF;
|
|
603
|
+
IF contact IS NULL AND p_external_contact IS NOT NULL AND sponsor IS NOT NULL THEN
|
|
604
|
+
INSERT INTO semicolony.action_external_contacts(display_name,metadata) VALUES(p_external_contact,jsonb_build_object('classification',coalesce(p_metadata->'classification','{}'::jsonb))) RETURNING external_contact_id INTO contact;
|
|
605
|
+
END IF;
|
|
606
|
+
IF contact IS NOT NULL AND (sponsor IS NULL OR p_assignee_kind<>'unassigned') THEN RAISE EXCEPTION 'external work requires internal sponsor' USING ERRCODE='22023'; END IF;
|
|
607
|
+
id:=semicolony.create_action_item(p_description,p_project_domain,p_deadline,p_assignee_kind,p_assignee_member,p_assignee_agent,p_assignee_device,p_priority,p_source_kind,p_source_ref,p_related_url,p_external_contact,p_metadata,p_origin_surface,p_origin_actor_kind,p_origin_member,p_origin_agent,p_origin_device,p_origin_harness,p_origin_session_key);
|
|
608
|
+
UPDATE semicolony.action_items SET external_contact_id=contact,sponsor_member_id=sponsor,
|
|
609
|
+
waiting_on_external=(contact IS NOT NULL) WHERE action_item_id=id;
|
|
610
|
+
RETURN id;
|
|
611
|
+
END $$;
|
|
612
|
+
REVOKE ALL ON FUNCTION semicolony.create_action_item_v2(text,text,date,text,text,text,text,text,text,text,text,text,jsonb,text,text,text,text,text,text,text,uuid,text) FROM PUBLIC;
|
|
613
|
+
GRANT EXECUTE ON FUNCTION semicolony.create_action_item_v2(text,text,date,text,text,text,text,text,text,text,text,text,jsonb,text,text,text,text,text,text,text,uuid,text) TO sc_app,app;
|
|
614
|
+
|
|
615
|
+
-- Authenticated Slack confirmation uses the same candidate owner boundary.
|
|
616
|
+
CREATE FUNCTION semicolony.apply_slack_candidate_resolution(p_requester_slack_user_id text,p_candidate_id uuid,
|
|
617
|
+
p_decision text,p_expected_version bigint,p_fields jsonb,p_note text,p_source_ref text) RETURNS uuid
|
|
618
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
|
|
619
|
+
DECLARE id uuid;
|
|
620
|
+
BEGIN
|
|
621
|
+
IF p_expected_version IS NULL OR length(btrim(coalesce(p_source_ref,'')))=0 THEN RAISE EXCEPTION 'version and Slack source required' USING ERRCODE='22023'; END IF;
|
|
622
|
+
id:=semicolony.resolve_action_candidate(p_candidate_id,p_decision,p_expected_version,p_fields,p_note,
|
|
623
|
+
'team-member',semicolony.action_slack_member(p_requester_slack_user_id),NULL);
|
|
624
|
+
UPDATE semicolony.action_item_candidates SET resolution_source_ref=coalesce(resolution_source_ref,p_source_ref) WHERE candidate_id=p_candidate_id;
|
|
625
|
+
RETURN id;
|
|
626
|
+
END $$;
|
|
627
|
+
REVOKE ALL ON FUNCTION semicolony.apply_slack_candidate_resolution(text,uuid,text,bigint,jsonb,text,text) FROM PUBLIC;
|
|
628
|
+
GRANT EXECUTE ON FUNCTION semicolony.apply_slack_candidate_resolution(text,uuid,text,bigint,jsonb,text,text) TO semo_ops_action_loop,app;
|
|
629
|
+
|
|
630
|
+
REVOKE ALL ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) FROM PUBLIC;
|
|
631
|
+
GRANT EXECUTE ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) TO sc_app,app,semo_ops_action_loop;
|
|
632
|
+
DO $$ BEGIN IF EXISTS(SELECT 1 FROM pg_roles WHERE rolname='sc_fleet_board') THEN
|
|
633
|
+
GRANT EXECUTE ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) TO sc_fleet_board;
|
|
634
|
+
END IF; END $$;
|