@team-semicolon/semicolony-cli 4.18.120 → 4.18.121
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 +1305 -1298
- package/migrations/210_skill_release_chain_role.sql +78 -0
- package/migrations/211_ops_board_skill_catalog_read.sql +64 -0
- package/migrations/212_feedback_status_vocabulary.sql +116 -0
- package/migrations/213_feedback_policy_and_events.sql +65 -0
- package/migrations/214_resolve_feedback.sql +140 -0
- package/migrations/215_record_feedback_recurrence.sql +424 -0
- package/migrations/216_ops_board_feedback_reverse.sql +49 -0
- package/migrations/217_feedback_merge_distance_tighten.sql +72 -0
- package/package.json +1 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
-- 215_record_feedback_recurrence.sql
|
|
2
|
+
--
|
|
3
|
+
-- 재발을 포착 시점에 판정한다.
|
|
4
|
+
--
|
|
5
|
+
-- 지금 중복 판정은 `p_slug` 일치뿐인데, slug 끝에 내용 해시 8자가 붙어 있어 사실상
|
|
6
|
+
-- 발동하지 않는다. 그래서 같은 실패의 재보고가 매번 새 레코드가 됐고, 2026-08-25 기준
|
|
7
|
+
-- open 634건이 쌓였다.
|
|
8
|
+
--
|
|
9
|
+
-- 거리 3구간으로 가른다(문턱은 semo_ops.feedback_policy). 병합 구간 아래는 제목까지
|
|
10
|
+
-- 같은 재보고이므로 접고, 그 위 연결 구간은 서비스가 다른 건과 같은 파일 건이 섞여
|
|
11
|
+
-- 거리로 못 가르므로 표시만 하고 트리아지에 넘긴다.
|
|
12
|
+
--
|
|
13
|
+
-- 비용은 실측 20.6ms(828행 전량 스캔)다. 앞선 임베딩 API 호출이 그보다 한 자릿수 크다.
|
|
14
|
+
--
|
|
15
|
+
-- 반환에 merged·occurrences 두 열이 붙으므로 CREATE OR REPLACE 만으로는 바꿀 수 없다
|
|
16
|
+
-- ("cannot change return type of existing function" — 2026-08-31 실측). 먼저 떨어뜨리고
|
|
17
|
+
-- 다시 만든 뒤, 재생성으로 PUBLIC 으로 되돌아간 실행 권한을 원래대로(app + sc_app)
|
|
18
|
+
-- 복원한다. 되돌릴 때도 같은 이유로 DROP 이 먼저다.
|
|
19
|
+
|
|
20
|
+
DROP FUNCTION IF EXISTS semo_ops.record_feedback(
|
|
21
|
+
text, text, text, jsonb, text, text, text
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE OR REPLACE FUNCTION semo_ops.record_feedback(
|
|
25
|
+
p_slug text,
|
|
26
|
+
p_summary text,
|
|
27
|
+
p_content text,
|
|
28
|
+
p_metadata jsonb,
|
|
29
|
+
p_embedding text,
|
|
30
|
+
p_worker_id text,
|
|
31
|
+
p_device_id text
|
|
32
|
+
)
|
|
33
|
+
RETURNS TABLE(kb_id bigint, action_item_id uuid, duplicate boolean,
|
|
34
|
+
merged boolean, occurrences int)
|
|
35
|
+
LANGUAGE plpgsql
|
|
36
|
+
SECURITY DEFINER
|
|
37
|
+
SET search_path TO 'pg_catalog'
|
|
38
|
+
AS $function$
|
|
39
|
+
DECLARE
|
|
40
|
+
v_kb_id bigint;
|
|
41
|
+
v_action_item_id uuid;
|
|
42
|
+
v_duplicate boolean := false;
|
|
43
|
+
v_merged boolean := false;
|
|
44
|
+
v_occurrences int := 1;
|
|
45
|
+
v_metadata jsonb;
|
|
46
|
+
v_priority text;
|
|
47
|
+
v_source text;
|
|
48
|
+
-- 재발 판정
|
|
49
|
+
v_merge_distance numeric;
|
|
50
|
+
v_link_distance numeric;
|
|
51
|
+
v_promote_occurrences int;
|
|
52
|
+
v_promote_distinct_hosts int;
|
|
53
|
+
v_near_id bigint;
|
|
54
|
+
v_near_slug text;
|
|
55
|
+
v_near_status text;
|
|
56
|
+
v_near_meta jsonb;
|
|
57
|
+
v_near_severity text;
|
|
58
|
+
v_distance numeric;
|
|
59
|
+
v_hosts jsonb;
|
|
60
|
+
v_distinct_hosts int;
|
|
61
|
+
v_new_status text;
|
|
62
|
+
v_event_kind text;
|
|
63
|
+
v_promoted boolean := false;
|
|
64
|
+
BEGIN
|
|
65
|
+
-- ── 검증 블록 (기존 정의에서 한 글자도 바꾸지 않고 그대로 옮겼다) ────
|
|
66
|
+
IF p_slug IS NULL
|
|
67
|
+
OR char_length(p_slug) < 3
|
|
68
|
+
OR char_length(p_slug) > 60
|
|
69
|
+
OR p_slug !~ '^[[:alnum:]가-힣][[:alnum:]가-힣-]*$' THEN
|
|
70
|
+
RAISE EXCEPTION 'invalid feedback slug' USING ERRCODE = '22023';
|
|
71
|
+
END IF;
|
|
72
|
+
IF p_summary IS NULL OR char_length(btrim(p_summary)) < 8 OR char_length(p_summary) > 500 THEN
|
|
73
|
+
RAISE EXCEPTION 'invalid feedback summary' USING ERRCODE = '22023';
|
|
74
|
+
END IF;
|
|
75
|
+
IF p_content IS NULL OR char_length(p_content) < 30 OR char_length(p_content) > 30000 THEN
|
|
76
|
+
RAISE EXCEPTION 'invalid feedback content' USING ERRCODE = '22023';
|
|
77
|
+
END IF;
|
|
78
|
+
IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
|
|
79
|
+
RAISE EXCEPTION 'invalid feedback metadata' USING ERRCODE = '22023';
|
|
80
|
+
END IF;
|
|
81
|
+
IF p_metadata ->> 'category' NOT IN ('context-failure', 'env-drift', 'policy-gap', 'tooling') THEN
|
|
82
|
+
RAISE EXCEPTION 'invalid feedback category' USING ERRCODE = '22023';
|
|
83
|
+
END IF;
|
|
84
|
+
v_priority := coalesce(p_metadata ->> 'severity', 'normal');
|
|
85
|
+
IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
|
|
86
|
+
RAISE EXCEPTION 'invalid feedback severity' USING ERRCODE = '22023';
|
|
87
|
+
END IF;
|
|
88
|
+
IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
|
|
89
|
+
OR p_device_id IS NULL
|
|
90
|
+
OR (p_device_id !~ '^[a-z][a-z0-9-]{0,99}$'
|
|
91
|
+
AND p_device_id <> 'hermes-principals@hermes') THEN
|
|
92
|
+
RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
|
|
93
|
+
END IF;
|
|
94
|
+
|
|
95
|
+
v_source := coalesce(p_metadata ->> 'source', 'session-hook');
|
|
96
|
+
IF v_source NOT IN ('session-hook', 'runtime-native-tool') THEN
|
|
97
|
+
RAISE EXCEPTION 'invalid feedback source' USING ERRCODE = '22023';
|
|
98
|
+
END IF;
|
|
99
|
+
IF v_source = 'runtime-native-tool' AND NOT (
|
|
100
|
+
p_worker_id IN ('semi', 'colony')
|
|
101
|
+
AND p_device_id = 'hermes-principals@hermes'
|
|
102
|
+
AND p_metadata ->> 'reported_by' = p_worker_id
|
|
103
|
+
AND p_metadata ->> 'installation_id' = 'hermes-principals@hermes'
|
|
104
|
+
AND p_metadata ->> 'runtime_kind' = 'hermes-cli'
|
|
105
|
+
AND p_metadata ->> 'profile_id' = 'semo-ops-worker-agent'
|
|
106
|
+
AND (
|
|
107
|
+
(p_worker_id = 'semi' AND p_metadata ->> 'runtime_profile' = 'semo-semi')
|
|
108
|
+
OR (p_worker_id = 'colony' AND p_metadata ->> 'runtime_profile' = 'semo-colony')
|
|
109
|
+
)
|
|
110
|
+
) THEN
|
|
111
|
+
RAISE EXCEPTION 'invalid runtime-native feedback attribution' USING ERRCODE = '22023';
|
|
112
|
+
END IF;
|
|
113
|
+
|
|
114
|
+
PERFORM p_embedding::public.vector;
|
|
115
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
|
|
116
|
+
|
|
117
|
+
v_priority := coalesce(p_metadata ->> 'severity', 'normal');
|
|
118
|
+
v_source := coalesce(p_metadata ->> 'source', 'session-hook');
|
|
119
|
+
|
|
120
|
+
-- ── 정책 ─────────────────────────────────────────────────────────
|
|
121
|
+
SELECT fp.merge_distance, fp.link_distance,
|
|
122
|
+
fp.promote_occurrences, fp.promote_distinct_hosts
|
|
123
|
+
INTO v_merge_distance, v_link_distance,
|
|
124
|
+
v_promote_occurrences, v_promote_distinct_hosts
|
|
125
|
+
FROM semo_ops.feedback_policy fp
|
|
126
|
+
WHERE fp.id = 1;
|
|
127
|
+
|
|
128
|
+
IF NOT FOUND THEN
|
|
129
|
+
-- 부재는 기본값이 아니다. 문턱을 지어내느니 포착을 실패시킨다 —
|
|
130
|
+
-- 실패는 보이지만 잘못된 기본값은 안 보인다.
|
|
131
|
+
RAISE EXCEPTION 'semo_ops.feedback_policy has no row; refusing to guess the recurrence thresholds'
|
|
132
|
+
USING ERRCODE = 'P0002';
|
|
133
|
+
END IF;
|
|
134
|
+
|
|
135
|
+
-- ── 가장 가까운 기존 레코드 ──────────────────────────────────────
|
|
136
|
+
-- 같은 slug 를 제외하지 않는다. 같은 slug 의 재보고가 바로 재발이고, 그 경우
|
|
137
|
+
-- 거리는 0 이라 병합 경로가 받는다 — 예전 ON CONFLICT 가 fixed 를 open 으로
|
|
138
|
+
-- 되돌리던 자리를 이 경로가 가져간다.
|
|
139
|
+
--
|
|
140
|
+
-- 거리 연산자는 스키마를 붙여 쓴다. 이 함수의 search_path 는 pg_catalog 하나뿐이라
|
|
141
|
+
-- 맨 <=> 는 "operator does not exist" 로 죽는다 (2026-08-31 실측).
|
|
142
|
+
SELECT k.kb_id, k.sub_key, coalesce(k.metadata ->> 'status', 'open'),
|
|
143
|
+
coalesce(k.metadata, '{}'::jsonb),
|
|
144
|
+
(k.embedding OPERATOR(public.<=>) p_embedding::public.vector)
|
|
145
|
+
INTO v_near_id, v_near_slug, v_near_status, v_near_meta, v_distance
|
|
146
|
+
FROM semo_ops.knowledge_base k
|
|
147
|
+
WHERE k.domain = 'semicolon' AND k.key = 'feedback'
|
|
148
|
+
AND coalesce(k.archived, false) = false
|
|
149
|
+
AND k.embedding IS NOT NULL
|
|
150
|
+
ORDER BY k.embedding OPERATOR(public.<=>) p_embedding::public.vector
|
|
151
|
+
LIMIT 1;
|
|
152
|
+
|
|
153
|
+
-- ── 병합 구간 ────────────────────────────────────────────────────
|
|
154
|
+
IF v_near_id IS NOT NULL AND v_distance < v_merge_distance THEN
|
|
155
|
+
v_merged := true;
|
|
156
|
+
v_occurrences := coalesce((v_near_meta ->> 'occurrences')::int, 1) + 1;
|
|
157
|
+
|
|
158
|
+
-- 호스트는 중복 없이 모은다. 두 번째 기기의 첫 재발이 횟수보다 강한 신호다 —
|
|
159
|
+
-- open 634건 중 620건이 한 기기에서 나왔기 때문이다.
|
|
160
|
+
--
|
|
161
|
+
-- 대상에 device_id 가 없으면 보고한 기기로 시드한다. 우리는 원래 호스트를 모르는
|
|
162
|
+
-- 것이지, 다른 호스트인 것이 아니다. 모르면 같은 것으로 센다 — 그래야 진짜 두 번째
|
|
163
|
+
-- 기기가 나타났을 때만 2가 된다. 자리표시자를 하나 끼워 넣으면 device_id 없는 행
|
|
164
|
+
-- (2026-08-31 실측: open 738건 중 363건, fixed 179건 중 112건)에서 첫 재발만으로
|
|
165
|
+
-- distinct_hosts 가 2가 되어 승격의 호스트 조건이 늘 참이 된다.
|
|
166
|
+
v_hosts := coalesce(v_near_meta -> 'occurrence_hosts',
|
|
167
|
+
to_jsonb(ARRAY[coalesce(v_near_meta ->> 'device_id', p_device_id)]));
|
|
168
|
+
IF NOT (v_hosts @> to_jsonb(ARRAY[p_device_id])) THEN
|
|
169
|
+
v_hosts := v_hosts || to_jsonb(ARRAY[p_device_id]);
|
|
170
|
+
END IF;
|
|
171
|
+
v_distinct_hosts := jsonb_array_length(v_hosts);
|
|
172
|
+
|
|
173
|
+
v_near_severity := coalesce(v_near_meta ->> 'severity', 'normal');
|
|
174
|
+
v_new_status := v_near_status;
|
|
175
|
+
v_event_kind := 'merged';
|
|
176
|
+
|
|
177
|
+
IF v_near_status = 'fixed' THEN
|
|
178
|
+
-- 고쳤는데 돌아왔다. 이 시스템이 낼 수 있는 가장 값진 신호라 자율 기각이
|
|
179
|
+
-- 개입하지 않는다 — 문턱을 보지 않고 바로 연다.
|
|
180
|
+
v_new_status := 'open';
|
|
181
|
+
v_event_kind := 'regressed';
|
|
182
|
+
v_near_severity := CASE v_near_severity
|
|
183
|
+
WHEN 'low' THEN 'normal' WHEN 'normal' THEN 'high'
|
|
184
|
+
WHEN 'high' THEN 'urgent' ELSE 'urgent' END;
|
|
185
|
+
v_promoted := true;
|
|
186
|
+
|
|
187
|
+
ELSIF v_near_status = 'rejected' THEN
|
|
188
|
+
IF v_occurrences >= v_promote_occurrences
|
|
189
|
+
OR v_distinct_hosts >= v_promote_distinct_hosts THEN
|
|
190
|
+
v_new_status := 'open';
|
|
191
|
+
v_event_kind := 'promoted';
|
|
192
|
+
v_promoted := true;
|
|
193
|
+
END IF;
|
|
194
|
+
END IF;
|
|
195
|
+
|
|
196
|
+
UPDATE semo_ops.knowledge_base AS kb
|
|
197
|
+
SET content = kb.content
|
|
198
|
+
|| E'\n\n## 재발 ' || to_char(clock_timestamp(), 'YYYY-MM-DD')
|
|
199
|
+
|| E'\n\n- 호스트: ' || p_device_id
|
|
200
|
+
|| E'\n- 세션: ' || coalesce(p_metadata ->> 'session_id', '-')
|
|
201
|
+
|| E'\n- 요약: ' || btrim(p_summary)
|
|
202
|
+
|| E'\n- 거리: ' || round(v_distance, 4)
|
|
203
|
+
|| E'\n',
|
|
204
|
+
metadata = coalesce(kb.metadata, '{}'::jsonb)
|
|
205
|
+
|| jsonb_build_object(
|
|
206
|
+
'status', v_new_status,
|
|
207
|
+
'severity', v_near_severity,
|
|
208
|
+
'occurrences', v_occurrences,
|
|
209
|
+
'occurrence_hosts', v_hosts,
|
|
210
|
+
'last_occurred_at', to_char(clock_timestamp(),
|
|
211
|
+
'YYYY-MM-DD"T"HH24:MI:SSOF'),
|
|
212
|
+
-- 생기지 않은 slug. 병합이 틀렸을 때 되짚는 실이다.
|
|
213
|
+
'merged_from',
|
|
214
|
+
coalesce(kb.metadata -> 'merged_from', '[]'::jsonb)
|
|
215
|
+
|| to_jsonb(ARRAY[p_slug]))
|
|
216
|
+
|| CASE WHEN v_event_kind = 'regressed'
|
|
217
|
+
THEN jsonb_build_object('regressed_at',
|
|
218
|
+
to_char(clock_timestamp(), 'YYYY-MM-DD"T"HH24:MI:SSOF'))
|
|
219
|
+
ELSE '{}'::jsonb END
|
|
220
|
+
|| CASE WHEN v_event_kind = 'promoted'
|
|
221
|
+
THEN jsonb_build_object('promoted_at',
|
|
222
|
+
to_char(clock_timestamp(), 'YYYY-MM-DD"T"HH24:MI:SSOF'))
|
|
223
|
+
ELSE '{}'::jsonb END
|
|
224
|
+
-- 승격·회귀로 다시 열리면 기각 사유는 이력이지 현재가 아니다.
|
|
225
|
+
|| CASE WHEN v_new_status = 'open'
|
|
226
|
+
THEN jsonb_build_object('rejected_reason', NULL)
|
|
227
|
+
ELSE '{}'::jsonb END,
|
|
228
|
+
archived = false,
|
|
229
|
+
updated_at = clock_timestamp()
|
|
230
|
+
WHERE kb.kb_id = v_near_id;
|
|
231
|
+
|
|
232
|
+
INSERT INTO semo_ops.feedback_merge_events
|
|
233
|
+
(kind, source_slug, target_slug, distance, occurrences_after,
|
|
234
|
+
distinct_hosts_after, target_status_before, metadata)
|
|
235
|
+
VALUES
|
|
236
|
+
(v_event_kind, p_slug, v_near_slug, v_distance, v_occurrences,
|
|
237
|
+
v_distinct_hosts, v_near_status,
|
|
238
|
+
jsonb_build_object('device_id', p_device_id, 'worker_id', p_worker_id,
|
|
239
|
+
'category', p_metadata ->> 'category'));
|
|
240
|
+
|
|
241
|
+
-- 다시 열렸는데 살아 있는 액션아이템이 없으면 만든다. 승격이 화면에 안 보이면
|
|
242
|
+
-- 승격이 아니다.
|
|
243
|
+
SELECT ai.action_item_id INTO v_action_item_id
|
|
244
|
+
FROM semo_ops.action_items ai
|
|
245
|
+
WHERE ai.source_kind = 'feedback' AND ai.source_ref = v_near_slug
|
|
246
|
+
AND ai.status IN ('open', 'in_progress', 'blocked')
|
|
247
|
+
ORDER BY ai.created_at DESC LIMIT 1;
|
|
248
|
+
|
|
249
|
+
IF v_promoted AND v_action_item_id IS NULL THEN
|
|
250
|
+
v_action_item_id := semo_ops.create_action_item(
|
|
251
|
+
p_description => '[SEMO Ops feedback] ' || btrim(p_summary),
|
|
252
|
+
p_project_domain => 'semo-ops',
|
|
253
|
+
p_deadline => semo_ops.action_item_default_deadline(v_near_severity),
|
|
254
|
+
p_assignee_kind => 'unassigned',
|
|
255
|
+
p_assignee_member => NULL,
|
|
256
|
+
p_assignee_agent => NULL,
|
|
257
|
+
p_assignee_device => NULL,
|
|
258
|
+
p_priority => v_near_severity,
|
|
259
|
+
p_source_kind => 'feedback',
|
|
260
|
+
p_source_ref => v_near_slug,
|
|
261
|
+
p_related_url => 'semo kb get semicolon feedback ' || v_near_slug,
|
|
262
|
+
p_external_contact => NULL,
|
|
263
|
+
p_metadata => p_metadata,
|
|
264
|
+
p_origin_surface => 'worker',
|
|
265
|
+
p_origin_actor_kind => CASE WHEN v_source = 'runtime-native-tool'
|
|
266
|
+
THEN 'agent' ELSE 'human' END,
|
|
267
|
+
p_origin_member => CASE WHEN v_source = 'runtime-native-tool'
|
|
268
|
+
THEN NULL ELSE p_worker_id END,
|
|
269
|
+
p_origin_agent => CASE WHEN v_source = 'runtime-native-tool'
|
|
270
|
+
THEN p_worker_id ELSE NULL END,
|
|
271
|
+
p_origin_device => p_device_id,
|
|
272
|
+
p_origin_harness => CASE WHEN v_source = 'runtime-native-tool'
|
|
273
|
+
THEN NULL ELSE 'cli' END,
|
|
274
|
+
p_origin_session_key => p_metadata ->> 'session_key');
|
|
275
|
+
END IF;
|
|
276
|
+
|
|
277
|
+
RETURN QUERY SELECT v_near_id, v_action_item_id, true, true, v_occurrences;
|
|
278
|
+
RETURN;
|
|
279
|
+
END IF;
|
|
280
|
+
|
|
281
|
+
-- ── 연결 구간 ────────────────────────────────────────────────────
|
|
282
|
+
-- 자동 병합하지 않는다. 이 거리대에는 서비스가 다른 건(SemiClass ↔ semi-colon-ops)과
|
|
283
|
+
-- 같은 파일 건이 섞여 있고, 거리로는 그 둘이 갈리지 않는다. 표시만 하고 트리아지가 본다.
|
|
284
|
+
v_metadata := p_metadata || jsonb_build_object(
|
|
285
|
+
'status', 'open',
|
|
286
|
+
'source', v_source,
|
|
287
|
+
'worker_id', p_worker_id,
|
|
288
|
+
'device_id', p_device_id,
|
|
289
|
+
'occurrences', 1,
|
|
290
|
+
'occurrence_hosts', to_jsonb(ARRAY[p_device_id]));
|
|
291
|
+
|
|
292
|
+
IF v_near_id IS NOT NULL AND v_distance < v_link_distance THEN
|
|
293
|
+
v_metadata := v_metadata || jsonb_build_object('related_to', v_near_slug,
|
|
294
|
+
'related_distance', round(v_distance, 5));
|
|
295
|
+
INSERT INTO semo_ops.feedback_merge_events
|
|
296
|
+
(kind, source_slug, target_slug, distance, occurrences_after,
|
|
297
|
+
distinct_hosts_after, target_status_before, metadata)
|
|
298
|
+
VALUES ('linked', p_slug, v_near_slug, v_distance, 1, 1, v_near_status,
|
|
299
|
+
jsonb_build_object('device_id', p_device_id));
|
|
300
|
+
END IF;
|
|
301
|
+
|
|
302
|
+
-- ── 신규 ────────────────────────────────────────────────────────
|
|
303
|
+
INSERT INTO semo_ops.knowledge_base AS kb (
|
|
304
|
+
domain, key, sub_key, content, created_by, embedding, metadata
|
|
305
|
+
) VALUES (
|
|
306
|
+
'semicolon', 'feedback', p_slug, p_content,
|
|
307
|
+
'feedback-pipeline:' || p_worker_id, p_embedding::public.vector, v_metadata
|
|
308
|
+
)
|
|
309
|
+
-- 같은 slug 재보고는 새 기록이 아니라 재발이다. 최근접 탐색이 못 본 행
|
|
310
|
+
-- (archived 이거나 임베딩이 없는 행)만 여기로 오므로, 병합 경로와 같은 규칙을
|
|
311
|
+
-- 적용해야 카운터와 이력이 살아남는다.
|
|
312
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
313
|
+
-- 본문을 덮어쓰지 않는다. 병합 경로처럼 재발 절을 덧붙인다 — 덮어쓰면 그동안
|
|
314
|
+
-- 쌓인 `## 재발` 절이 통째로 사라진다.
|
|
315
|
+
content = kb.content
|
|
316
|
+
|| E'\n\n## 재발 ' || to_char(clock_timestamp(), 'YYYY-MM-DD')
|
|
317
|
+
|| E'\n\n- 호스트: ' || p_device_id
|
|
318
|
+
|| E'\n- 세션: ' || coalesce(p_metadata ->> 'session_id', '-')
|
|
319
|
+
|| E'\n- 요약: ' || btrim(p_summary)
|
|
320
|
+
|| E'\n- 경로: 같은 slug 재보고 (최근접 탐색이 보지 못한 행)'
|
|
321
|
+
|| E'\n',
|
|
322
|
+
embedding = EXCLUDED.embedding,
|
|
323
|
+
-- status 를 빼고 병합한다. 예전 판은 무조건 'open' 으로 덮어써서, 같은 slug
|
|
324
|
+
-- 재보고가 이미 fixed 인 항목을 조용히 되돌렸다.
|
|
325
|
+
--
|
|
326
|
+
-- 재발 카운터 셋도 함께 뺀다. EXCLUDED 는 언제나 occurrences=1 과 이번 기기
|
|
327
|
+
-- 하나짜리 occurrence_hosts 를 들고 오므로, 그대로 병합하면 그동안 센 횟수와
|
|
328
|
+
-- 호스트 목록과 merged_from 이 첫 재보고 한 번으로 0으로 돌아간다. 자율 기각
|
|
329
|
+
-- 아래에서 이 카운터가 유일한 안전망이라 조용히 지워지는 것이 최악이다.
|
|
330
|
+
--
|
|
331
|
+
-- 빼놓고 끝내지 않는다. 이 경로도 재발이므로 횟수를 올리고 호스트를 중복 없이
|
|
332
|
+
-- 더한다. 세는 자리를 비워 두면 트리아지가 보는 숫자가 재보고 때마다 제자리다.
|
|
333
|
+
--
|
|
334
|
+
-- 다만 상태 반응(승격·회귀)은 여기서 하지 않는다. 이 경로는 최근접 탐색이 병합
|
|
335
|
+
-- 거리 안에서 아무것도 못 찾았는데 slug 는 이미 있는 경우 — 즉 기존 행이
|
|
336
|
+
-- archived 이거나 임베딩이 없는 경우 — 에만 닿는다. archived 중복은 그 문제의
|
|
337
|
+
-- 정본이 아니다. 정본은 그 병합 대상이고 대상은 archived 가 아니므로 최근접이
|
|
338
|
+
-- 거리 0 으로 찾아 병합 경로에서 이미 끝난다. 그래서 실제로 여기 오는 것은
|
|
339
|
+
-- 임베딩 없는 레거시 행의 재보고이고, 거기서 승격을 판정할 근거가 우리에겐 없다.
|
|
340
|
+
-- 횟수는 오르므로 트리아지에는 보인다.
|
|
341
|
+
metadata = coalesce(kb.metadata, '{}'::jsonb)
|
|
342
|
+
|| (EXCLUDED.metadata - 'status' - 'occurrences' - 'occurrence_hosts' - 'merged_from')
|
|
343
|
+
|| jsonb_build_object(
|
|
344
|
+
'occurrences', coalesce((kb.metadata ->> 'occurrences')::int, 0) + 1,
|
|
345
|
+
'occurrence_hosts',
|
|
346
|
+
CASE WHEN coalesce(kb.metadata -> 'occurrence_hosts',
|
|
347
|
+
to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
|
|
348
|
+
p_device_id)]))
|
|
349
|
+
@> to_jsonb(ARRAY[p_device_id])
|
|
350
|
+
THEN coalesce(kb.metadata -> 'occurrence_hosts',
|
|
351
|
+
to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
|
|
352
|
+
p_device_id)]))
|
|
353
|
+
ELSE coalesce(kb.metadata -> 'occurrence_hosts',
|
|
354
|
+
to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
|
|
355
|
+
p_device_id)]))
|
|
356
|
+
|| to_jsonb(ARRAY[p_device_id])
|
|
357
|
+
END),
|
|
358
|
+
archived = false,
|
|
359
|
+
updated_at = clock_timestamp()
|
|
360
|
+
-- 저장된 값을 그대로 돌려준다. 리터럴 1 은 "4번째 재발" 을 1번째라고 말한다.
|
|
361
|
+
RETURNING kb.kb_id, coalesce((kb.metadata ->> 'occurrences')::int, 1)
|
|
362
|
+
INTO v_kb_id, v_occurrences;
|
|
363
|
+
|
|
364
|
+
SELECT ai.action_item_id INTO v_action_item_id
|
|
365
|
+
FROM semo_ops.action_items AS ai
|
|
366
|
+
WHERE ai.source_kind = 'feedback'
|
|
367
|
+
AND ai.status IN ('open', 'in_progress', 'blocked')
|
|
368
|
+
AND ai.source_ref = p_slug
|
|
369
|
+
ORDER BY ai.created_at DESC
|
|
370
|
+
LIMIT 1
|
|
371
|
+
FOR UPDATE;
|
|
372
|
+
|
|
373
|
+
IF v_action_item_id IS NULL THEN
|
|
374
|
+
SELECT true INTO v_duplicate
|
|
375
|
+
FROM semo_ops.action_items_archive_20260812 AS old
|
|
376
|
+
WHERE old.source = 'feedback'
|
|
377
|
+
AND old.status IN ('completed', 'cancelled')
|
|
378
|
+
AND old.metadata ->> 'feedback_slug' = p_slug
|
|
379
|
+
LIMIT 1;
|
|
380
|
+
v_duplicate := coalesce(v_duplicate, false);
|
|
381
|
+
ELSE
|
|
382
|
+
v_duplicate := true;
|
|
383
|
+
END IF;
|
|
384
|
+
|
|
385
|
+
IF NOT v_duplicate THEN
|
|
386
|
+
v_action_item_id := semo_ops.create_action_item(
|
|
387
|
+
p_description => '[SEMO Ops feedback] ' || btrim(p_summary),
|
|
388
|
+
p_project_domain => 'semo-ops',
|
|
389
|
+
p_deadline => semo_ops.action_item_default_deadline(v_priority),
|
|
390
|
+
p_assignee_kind => 'unassigned',
|
|
391
|
+
p_assignee_member => NULL,
|
|
392
|
+
p_assignee_agent => NULL,
|
|
393
|
+
p_assignee_device => NULL,
|
|
394
|
+
p_priority => v_priority,
|
|
395
|
+
p_source_kind => 'feedback',
|
|
396
|
+
p_source_ref => p_slug,
|
|
397
|
+
p_related_url => 'semo kb get semicolon feedback ' || p_slug,
|
|
398
|
+
p_external_contact => NULL,
|
|
399
|
+
p_metadata => p_metadata,
|
|
400
|
+
p_origin_surface => 'worker',
|
|
401
|
+
p_origin_actor_kind => CASE WHEN v_source = 'runtime-native-tool'
|
|
402
|
+
THEN 'agent' ELSE 'human' END,
|
|
403
|
+
p_origin_member => CASE WHEN v_source = 'runtime-native-tool'
|
|
404
|
+
THEN NULL ELSE p_worker_id END,
|
|
405
|
+
p_origin_agent => CASE WHEN v_source = 'runtime-native-tool'
|
|
406
|
+
THEN p_worker_id ELSE NULL END,
|
|
407
|
+
p_origin_device => p_device_id,
|
|
408
|
+
p_origin_harness => CASE WHEN v_source = 'runtime-native-tool'
|
|
409
|
+
THEN NULL ELSE 'cli' END,
|
|
410
|
+
p_origin_session_key => p_metadata ->> 'session_key');
|
|
411
|
+
END IF;
|
|
412
|
+
|
|
413
|
+
RETURN QUERY SELECT v_kb_id, v_action_item_id, v_duplicate, false, v_occurrences;
|
|
414
|
+
END;
|
|
415
|
+
$function$;
|
|
416
|
+
|
|
417
|
+
-- 재생성으로 실행 권한이 PUBLIC 으로 되돌아갔다. 교체 전 ACL(app=X/app | sc_app=X/app)
|
|
418
|
+
-- 을 그대로 복원한다 — 게이트웨이는 sc_app 으로 붙는다.
|
|
419
|
+
REVOKE ALL ON FUNCTION semo_ops.record_feedback(
|
|
420
|
+
text, text, text, jsonb, text, text, text
|
|
421
|
+
) FROM PUBLIC;
|
|
422
|
+
GRANT EXECUTE ON FUNCTION semo_ops.record_feedback(
|
|
423
|
+
text, text, text, jsonb, text, text, text
|
|
424
|
+
) TO sc_app;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- 216_ops_board_feedback_reverse.sql
|
|
2
|
+
--
|
|
3
|
+
-- 자율 기각을 켜는 이상 사람이 되돌리는 경로가 UI 에 있어야 한다. 없으면 안전장치는
|
|
4
|
+
-- 문서에만 존재한다.
|
|
5
|
+
--
|
|
6
|
+
-- 보드 롤(`sc_fleet_board`)은 SELECT 만 갖는다. 그 롤에 표 쓰기를 열지 않고,
|
|
7
|
+
-- SECURITY DEFINER 함수 하나의 실행권만 준다 — 쓰기의 모양이 그 함수 안에 갇힌다.
|
|
8
|
+
--
|
|
9
|
+
-- SECURITY DEFINER 함수는 기본값이 PUBLIC 실행 가능이다. 권한을 좁히는 것은 GRANT 를
|
|
10
|
+
-- 더하는 것이 아니라 PUBLIC 을 걷어내는 것이다 — GRANT 만 쓰면 아무것도 닫히지 않는다.
|
|
11
|
+
-- 214 는 이걸 빠뜨렸다: 실측 결과 `resolve_feedback` 의 proacl 에 `=X/app`(PUBLIC) 이
|
|
12
|
+
-- 그대로 남아 있었고, worker Gateway 롤 `sc_app` 이 인터넷에서 닿는 롤이므로
|
|
13
|
+
-- 사실상 누구나 이 SECURITY DEFINER 쓰기 함수를 호출할 수 있었다(record_feedback 은
|
|
14
|
+
-- 215 가 이미 PUBLIC 을 걷어냈다 — 여기 214/216 만 빠졌던 것). REVOKE 를 먼저 하고
|
|
15
|
+
-- 그다음에 필요한 롤에만 명시적으로 GRANT 한다. `app` 은 소유자라 암묵적으로 유지되고,
|
|
16
|
+
-- `sc_app` 에는 이 함수를 주지 않는다 — 루프는 `update_action_item` →
|
|
17
|
+
-- `feedback_action_item_sync` 트리거로 이미 닫히고, sc_app 은 resolve_feedback 을
|
|
18
|
+
-- 부를 이유가 없다.
|
|
19
|
+
|
|
20
|
+
REVOKE ALL ON FUNCTION semo_ops.resolve_feedback(text, text, text, text, text) FROM PUBLIC;
|
|
21
|
+
|
|
22
|
+
GRANT EXECUTE ON FUNCTION semo_ops.resolve_feedback(text, text, text, text, text)
|
|
23
|
+
TO sc_fleet_board;
|
|
24
|
+
|
|
25
|
+
-- 실행권이 표 쓰기로 번지지 않았는지, 그리고 PUBLIC 이 다시 열리지 않았는지 확인한다.
|
|
26
|
+
DO $$
|
|
27
|
+
DECLARE
|
|
28
|
+
t text;
|
|
29
|
+
leaked text[] := '{}';
|
|
30
|
+
BEGIN
|
|
31
|
+
FOREACH t IN ARRAY ARRAY['knowledge_base', 'action_items'] LOOP
|
|
32
|
+
IF has_table_privilege('sc_fleet_board', 'semo_ops.' || quote_ident(t), 'UPDATE')
|
|
33
|
+
OR has_table_privilege('sc_fleet_board', 'semo_ops.' || quote_ident(t), 'INSERT')
|
|
34
|
+
OR has_table_privilege('sc_fleet_board', 'semo_ops.' || quote_ident(t), 'DELETE') THEN
|
|
35
|
+
leaked := leaked || t;
|
|
36
|
+
END IF;
|
|
37
|
+
END LOOP;
|
|
38
|
+
IF array_length(leaked, 1) IS NOT NULL THEN
|
|
39
|
+
RAISE EXCEPTION 'sc_fleet_board must reach % only through resolve_feedback',
|
|
40
|
+
array_to_string(leaked, ', ');
|
|
41
|
+
END IF;
|
|
42
|
+
|
|
43
|
+
-- PUBLIC 이 이 SECURITY DEFINER 함수를 다시 실행할 수 있으면, 인터넷에서 닿는
|
|
44
|
+
-- 어떤 롤(sc_app 포함)이든 임의로 피드백 상태를 뒤집을 수 있다. 미래의 마이그레이션이
|
|
45
|
+
-- 실수로 다시 열면 여기서 막혀야 한다.
|
|
46
|
+
IF has_function_privilege('public', 'semo_ops.resolve_feedback(text,text,text,text,text)', 'EXECUTE') THEN
|
|
47
|
+
RAISE EXCEPTION 'resolve_feedback must not be executable by PUBLIC (sc_app is internet-reachable)';
|
|
48
|
+
END IF;
|
|
49
|
+
END $$;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- 217_feedback_merge_distance_tighten.sql
|
|
2
|
+
--
|
|
3
|
+
-- merge_distance 를 0.100 에서 0.030 으로 좁힌다.
|
|
4
|
+
--
|
|
5
|
+
-- 213 의 0.10 은 2026-08-25 표본 4쌍(전부 d<0.007)에서 "0.10 아래는 제목까지 같은
|
|
6
|
+
-- 재보고" 라고 결론 낸 값이었다. semo-ops 백로그 전체에 대해 그 0.10 을 실제로 돌려
|
|
7
|
+
-- 26쌍을 사람이 전부 눈으로 확인한 결과(2026-09-01), 그 결론은 26쌍 규모에서는
|
|
8
|
+
-- 틀렸다:
|
|
9
|
+
--
|
|
10
|
+
-- - d=0.0511 Phase 5 Colony 캐너리(3343ff17…) vs Phase 5 Semi 캐너리(55672e1b…)
|
|
11
|
+
-- — 같은 캐너리 템플릿, 다른 봇. 같은 문제가 아니라 봇 둘의 서로 다른 사건.
|
|
12
|
+
-- - d=0.0632 Phase 7 Colony native feedback canary vs Phase 7 Semi — 위와 동일 패턴.
|
|
13
|
+
-- - d=0.0648 Phase 4 Hermes Colony vs Phase 4 Hermes Semi(같은 타임스탬프) — 위와 동일 패턴.
|
|
14
|
+
-- - d=0.0799 team-knowledge-fabric pipeline/config vs semi-colon-ops pipeline
|
|
15
|
+
-- metadata — 같은 정책을 다른 서비스에 적용한 별개 기록. 바로 이 모양(서비스가
|
|
16
|
+
-- 다른데 파일/정책 문구가 같아 거리로 못 가름)이 213 의 rationale 이 0.18 을
|
|
17
|
+
-- "병합 아님" 경계로 둔 이유였는데, 그 위험이 0.10 아래에서도 나타났다.
|
|
18
|
+
-- - d=0.0842 works-provision §5 배포물 표 — 한쪽은 "works-mcp 가 없다", 다른
|
|
19
|
+
-- 쪽은 "유닛 셋만 적혀 있다". 같은 절을 가리키지만 서로 다른 결함.
|
|
20
|
+
--
|
|
21
|
+
-- 경계 사례로 제외한 것도 있다: d=0.0928(DATABASE_URL 이중 경로 문제가 서로 다른
|
|
22
|
+
-- 두 문서에 보고됨 — 고칠 곳이 하나가 아니라 둘), d=0.0674.
|
|
23
|
+
--
|
|
24
|
+
-- 반대쪽 증거: 26쌍 중 "제목까지 같은 재보고" 로 의문의 여지가 없던 마지막 쌍은
|
|
25
|
+
-- d=0.0253 이었고, 그 위로 다음 쌍은 d=0.0511 이었다 — 0.0253 과 0.0511 사이에는
|
|
26
|
+
-- 관측된 쌍이 하나도 없다. 0.030 은 그 빈 구간 안에 있다 — 추측이 아니라 실측한
|
|
27
|
+
-- 빈 구간이다. 이 값은 명백히 같은 11쌍은 병합 구간에 남기고, 위 다섯 개를 포함한
|
|
28
|
+
-- 나머지 15쌍은 link_distance(0.18) 아래 "연결" 구간으로 내려보내 triage 세션이
|
|
29
|
+
-- 사람 눈으로 읽고 정하게 한다 — 그 세션이 바로 이런 모호함을 위해 설계됐다.
|
|
30
|
+
--
|
|
31
|
+
-- production 확인(2026-09-01): merge 는 아직 0건 실행됐다(linked 4건, 전부
|
|
32
|
+
-- 0.1087~0.1679, 즉 기존 0.10 문턱보다 위였다). 그러므로 이 마이그레이션은 되돌릴
|
|
33
|
+
-- 잘못된 병합이 없는 교정이지, 사후 정리가 아니다.
|
|
34
|
+
|
|
35
|
+
UPDATE semo_ops.feedback_policy
|
|
36
|
+
SET merge_distance = 0.030,
|
|
37
|
+
rationale =
|
|
38
|
+
'병합 거리를 0.10 → 0.030 으로 좁힌다(2026-09-01). 0.10 은 2026-08-25 표본 '
|
|
39
|
+
'4쌍(전부 d<0.007)에서 나온 값이었다. semo-ops 백로그 전체 26쌍에 그 0.10 을 '
|
|
40
|
+
'실제로 돌려 사람이 전부 확인한 결과, 0.0511 (Phase 5 Colony/Semi 캐너리 — 같은 '
|
|
41
|
+
'템플릿, 다른 봇), 0.0632 (Phase 7 Colony/Semi, 동일 패턴), 0.0648 (Phase 4 '
|
|
42
|
+
'Hermes Colony/Semi, 동일 패턴), 0.0799 (team-knowledge-fabric 대 '
|
|
43
|
+
'semi-colon-ops pipeline metadata — 서비스가 다른 별개 기록), 0.0842 '
|
|
44
|
+
'(works-provision §5 배포물 표 — 서로 다른 결함, works-mcp 부재 대 유닛 셋만 '
|
|
45
|
+
'나열) 다섯 쌍이 전부 오탐이었다. 반면 26쌍 중 의문의 여지 없는 마지막 재보고 '
|
|
46
|
+
'쌍은 0.0253 이었고 그 위 다음 쌍은 0.0511 — 그 사이엔 관측된 쌍이 없다. 0.030 '
|
|
47
|
+
'은 그 실측된 빈 구간에 있다. 위 다섯 쌍은 이제 link_distance(0.18) 아래 연결 '
|
|
48
|
+
'구간으로 내려가 triage 세션이 사람 눈으로 읽고 정한다. production 확인 결과 '
|
|
49
|
+
'이 문턱으로 실제 merge 가 실행된 적은 없다(linked 4건, 전부 0.1087~0.1679) — '
|
|
50
|
+
'되돌릴 병합이 없는 사전 교정이다. feedback_merge_events 가 새 문턱 아래에서 '
|
|
51
|
+
'몇 주 쌓이면 그 분포로 다시 정한다.',
|
|
52
|
+
updated_by = 'reus',
|
|
53
|
+
updated_at = now()
|
|
54
|
+
WHERE id = 1;
|
|
55
|
+
|
|
56
|
+
-- 정확히 한 행이 있고 그 행이 새 값을 들고 있는지 확인한다. 조용히 0행이 갱신되면
|
|
57
|
+
-- (예: id=1 행이 없었다면) 이후 record_feedback 은 여전히 낡은 0.10 을 읽는다.
|
|
58
|
+
DO $$
|
|
59
|
+
DECLARE
|
|
60
|
+
v_distance numeric;
|
|
61
|
+
v_count int;
|
|
62
|
+
BEGIN
|
|
63
|
+
SELECT count(*) INTO v_count FROM semo_ops.feedback_policy;
|
|
64
|
+
IF v_count <> 1 THEN
|
|
65
|
+
RAISE EXCEPTION 'semo_ops.feedback_policy must hold exactly one row, found %', v_count;
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
68
|
+
SELECT merge_distance INTO v_distance FROM semo_ops.feedback_policy WHERE id = 1;
|
|
69
|
+
IF v_distance IS DISTINCT FROM 0.030 THEN
|
|
70
|
+
RAISE EXCEPTION 'merge_distance did not converge to 0.030, found %', v_distance;
|
|
71
|
+
END IF;
|
|
72
|
+
END $$;
|