@team-semicolon/semicolony-cli 4.18.119 → 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 +1317 -1310
- 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,78 @@
|
|
|
1
|
+
-- 210_skill_release_chain_role.sql
|
|
2
|
+
--
|
|
3
|
+
-- A dedicated role for one operation: cutting the skill-only worker release that a governed
|
|
4
|
+
-- skill activation implies.
|
|
5
|
+
--
|
|
6
|
+
-- Why not widen `sc_app`. The Gateway connects as `sc_app`, which serves every worker device
|
|
7
|
+
-- and every bot and is reachable from the internet. Its current access to the release tables
|
|
8
|
+
-- is deliberately narrow — SELECT on named columns only, and `source_commit` is not among them
|
|
9
|
+
-- (177). Granting that role INSERT and UPDATE would mean a caller who reaches the Gateway with
|
|
10
|
+
-- a valid lease could change what every device installs. The line drawn in 177 is kept.
|
|
11
|
+
--
|
|
12
|
+
-- Instead `sc_skill_release` holds exactly the four privileges the patch path needs, and
|
|
13
|
+
-- `sc_app` is made a member so the Gateway can `SET LOCAL ROLE` for that transaction and drop
|
|
14
|
+
-- back immediately. Membership is not the same as the privilege: `sc_app`'s own grants are
|
|
15
|
+
-- unchanged, and the elevation is scoped to a transaction that the code enters deliberately.
|
|
16
|
+
-- The repository already separates service roles this way (`sc_fleet_board`, `sc_provider`,
|
|
17
|
+
-- `semo_ops_bot`), so this follows the existing shape rather than inventing one.
|
|
18
|
+
--
|
|
19
|
+
-- `NOINHERIT` is the point: without it `sc_app` would carry these privileges implicitly on
|
|
20
|
+
-- every statement, which is exactly what this migration is avoiding. With it, the privileges
|
|
21
|
+
-- apply only after an explicit `SET ROLE`.
|
|
22
|
+
--
|
|
23
|
+
-- Design: semo-ops/docs/superpowers/specs/2026-08-27-skill-activation-auto-worker-release-design.md
|
|
24
|
+
|
|
25
|
+
DO $$
|
|
26
|
+
BEGIN
|
|
27
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sc_skill_release') THEN
|
|
28
|
+
CREATE ROLE sc_skill_release NOLOGIN NOINHERIT;
|
|
29
|
+
END IF;
|
|
30
|
+
END $$;
|
|
31
|
+
|
|
32
|
+
-- 스키마 USAGE 는 재타겟을 탈 수 없다. 러너의 치환은 `semicolony.`(점 포함)만 바꾸므로
|
|
33
|
+
-- `ON SCHEMA semicolony` 는 활성 스키마(SEMICOLONY_DB_SCHEMA)를 따라가지 않는다. 테이블이
|
|
34
|
+
-- 실제로 있는 스키마에서 이름을 얻어 동적으로 부여한다 — 135_sc_roles 와 같은 형태이되
|
|
35
|
+
-- 러너가 설정하지 않는 `app.runtime_schema` 에 기대지 않는다.
|
|
36
|
+
DO $$
|
|
37
|
+
DECLARE v_schema text;
|
|
38
|
+
BEGIN
|
|
39
|
+
SELECT n.nspname INTO v_schema
|
|
40
|
+
FROM pg_class c
|
|
41
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
42
|
+
WHERE c.relname = 'worker_profile_releases' AND c.relkind = 'r'
|
|
43
|
+
ORDER BY (n.nspname = current_schema()) DESC, n.nspname
|
|
44
|
+
LIMIT 1;
|
|
45
|
+
IF v_schema IS NULL THEN
|
|
46
|
+
RAISE EXCEPTION 'worker_profile_releases not found; cannot determine the active schema';
|
|
47
|
+
END IF;
|
|
48
|
+
EXECUTE format('GRANT USAGE ON SCHEMA %I TO sc_skill_release', v_schema);
|
|
49
|
+
END $$;
|
|
50
|
+
|
|
51
|
+
-- Reading the release it patches. `source_commit` is required here and is precisely what
|
|
52
|
+
-- `sc_app` is not allowed to read on its own.
|
|
53
|
+
GRANT SELECT (release_id, profile_id, version, status, manifest, manifest_sha256, source_commit)
|
|
54
|
+
ON TABLE semicolony.worker_profile_releases TO sc_skill_release;
|
|
55
|
+
|
|
56
|
+
-- Staging the patch release, then activating it and superseding the previous one.
|
|
57
|
+
GRANT INSERT ON TABLE semicolony.worker_profile_releases TO sc_skill_release;
|
|
58
|
+
GRANT UPDATE (status, activated_at, activated_by)
|
|
59
|
+
ON TABLE semicolony.worker_profile_releases TO sc_skill_release;
|
|
60
|
+
|
|
61
|
+
-- Moving the profile pointer to the new release.
|
|
62
|
+
GRANT SELECT (profile_id, active_release_id)
|
|
63
|
+
ON TABLE semicolony.workspace_profiles TO sc_skill_release;
|
|
64
|
+
GRANT UPDATE (active_release_id)
|
|
65
|
+
ON TABLE semicolony.workspace_profiles TO sc_skill_release;
|
|
66
|
+
|
|
67
|
+
-- Activation refuses to proceed when a registered device cannot carry the manifest, so the
|
|
68
|
+
-- role must be able to read that compatibility signal. Read-only, and no device column is
|
|
69
|
+
-- writable from here — device state is reported by the device itself under `sc_app`.
|
|
70
|
+
GRANT SELECT (worker_id, device_id, status, installed_profile_id, last_doctor_status, last_doctor_summary)
|
|
71
|
+
ON TABLE semicolony.worker_devices TO sc_skill_release;
|
|
72
|
+
|
|
73
|
+
-- Membership only. `sc_app` keeps its own narrow grants; NOINHERIT means these privileges are
|
|
74
|
+
-- inert until the Gateway issues `SET LOCAL ROLE sc_skill_release` inside a transaction.
|
|
75
|
+
GRANT sc_skill_release TO sc_app;
|
|
76
|
+
|
|
77
|
+
COMMENT ON ROLE sc_skill_release IS
|
|
78
|
+
'Cuts the skill-only worker patch release implied by a governed skill activation. Assumed by sc_app via SET LOCAL ROLE for that transaction only; never a login role.';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- 211_ops_board_skill_catalog_read.sql
|
|
2
|
+
--
|
|
3
|
+
-- `/ops/skills` reads the skill release registry. The dashboard connects as
|
|
4
|
+
-- `sc_fleet_board`, which today has SELECT on **none** of the skill tables, so the
|
|
5
|
+
-- board would render zero rows with no error — the same silent-empty failure the
|
|
6
|
+
-- Works board hit before `0032` opened its tables.
|
|
7
|
+
--
|
|
8
|
+
-- What the catalogue needs, and nothing else:
|
|
9
|
+
-- * the two "current" views — what is active, what is retired
|
|
10
|
+
-- * `skill_releases` + `skill_release_activations` — the per-skill history behind them
|
|
11
|
+
-- * `skill_release_files` — the SKILL.md body, which is the whole point:
|
|
12
|
+
-- the repository rule is that a skill's source of truth is the approved release, and
|
|
13
|
+
-- an installed tree on disk is only a projection. A screen that renders the release
|
|
14
|
+
-- body directly is how that rule stops being folklore.
|
|
15
|
+
--
|
|
16
|
+
-- Deliberately NOT granted:
|
|
17
|
+
-- * `skill_capability_leases` / `skill_capability_lease_revocations` — who is allowed to
|
|
18
|
+
-- *write* a skill. That is authorization state, not catalogue, and a read-only board has
|
|
19
|
+
-- no question that needs it.
|
|
20
|
+
-- * `skill_change_proposals` — unapproved draft bodies. Showing a proposal next to an
|
|
21
|
+
-- activated release invites reading the draft as the skill.
|
|
22
|
+
-- * `project_skill_releases` — identity and hashes only, no file body (the project-scope
|
|
23
|
+
-- body still lives in Git). Listing it would imply a catalogue entry that cannot be read.
|
|
24
|
+
--
|
|
25
|
+
-- Same shape as `works` migration `0032`: grant the minimum, then assert the exclusions hold
|
|
26
|
+
-- so a later convenience grant fails here instead of quietly widening the board.
|
|
27
|
+
|
|
28
|
+
GRANT USAGE ON SCHEMA semo_ops TO sc_fleet_board;
|
|
29
|
+
|
|
30
|
+
GRANT SELECT ON TABLE
|
|
31
|
+
semo_ops.skill_releases,
|
|
32
|
+
semo_ops.skill_release_activations,
|
|
33
|
+
semo_ops.skill_release_retirements,
|
|
34
|
+
semo_ops.skill_release_files
|
|
35
|
+
TO sc_fleet_board;
|
|
36
|
+
|
|
37
|
+
GRANT SELECT ON TABLE
|
|
38
|
+
semo_ops.v_current_skill_release_activations,
|
|
39
|
+
semo_ops.v_current_skill_release_retirements
|
|
40
|
+
TO sc_fleet_board;
|
|
41
|
+
|
|
42
|
+
-- The board must stay unable to read authorization state or unapproved drafts.
|
|
43
|
+
DO $$
|
|
44
|
+
DECLARE
|
|
45
|
+
t text;
|
|
46
|
+
leaked text[] := '{}';
|
|
47
|
+
BEGIN
|
|
48
|
+
FOREACH t IN ARRAY ARRAY[
|
|
49
|
+
'skill_capability_leases',
|
|
50
|
+
'skill_capability_lease_revocations',
|
|
51
|
+
'skill_change_proposals',
|
|
52
|
+
'project_skill_releases'
|
|
53
|
+
] LOOP
|
|
54
|
+
IF to_regclass('semo_ops.' || quote_ident(t)) IS NOT NULL
|
|
55
|
+
AND has_table_privilege('sc_fleet_board', 'semo_ops.' || quote_ident(t), 'SELECT') THEN
|
|
56
|
+
leaked := leaked || t;
|
|
57
|
+
END IF;
|
|
58
|
+
END LOOP;
|
|
59
|
+
|
|
60
|
+
IF array_length(leaked, 1) IS NOT NULL THEN
|
|
61
|
+
RAISE EXCEPTION 'sc_fleet_board must not read %; the ops board is a catalogue, not a governance surface',
|
|
62
|
+
array_to_string(leaked, ', ');
|
|
63
|
+
END IF;
|
|
64
|
+
END $$;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- 212_feedback_status_vocabulary.sql
|
|
2
|
+
--
|
|
3
|
+
-- 피드백 상태를 넷으로 고정한다: open · rejected · fixed · waiting.
|
|
4
|
+
--
|
|
5
|
+
-- 2026-08-25 실측으로 라이브에 11종이 있었다. `mr-open-awaiting-human-merge` 나
|
|
6
|
+
-- `contained-pending-permanent-fix` 같은 1회성 문장이 섞여 있어서 "완수" 가 기계로
|
|
7
|
+
-- 판정되지 않았다. 그 값들은 `record_feedback` 이 아니라 `semo kb upsert` 로 들어왔다
|
|
8
|
+
-- — 그것도 정당한 쓰기 경로이므로, 함수가 아니라 **트리거**로 막는다.
|
|
9
|
+
--
|
|
10
|
+
-- 순서가 중요하다: 먼저 정규화하고 그다음 트리거를 건다. 반대로 하면 `duplicate` 를
|
|
11
|
+
-- 사유 없는 `rejected` 로 바꾸는 UPDATE 가 자기 트리거에 걸린다.
|
|
12
|
+
|
|
13
|
+
-- ── 1. 레거시 값 접기 ────────────────────────────────────────────────
|
|
14
|
+
-- 원래 값은 status_legacy 에 남긴다. 나중에 "이건 왜 fixed 인가" 를 물을 수 있어야 한다.
|
|
15
|
+
|
|
16
|
+
UPDATE semo_ops.knowledge_base
|
|
17
|
+
SET metadata = metadata
|
|
18
|
+
|| jsonb_build_object('status', 'fixed',
|
|
19
|
+
'status_legacy', metadata ->> 'status')
|
|
20
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
21
|
+
AND metadata ->> 'status' IN ('resolved', 'cleanup-verified');
|
|
22
|
+
|
|
23
|
+
UPDATE semo_ops.knowledge_base
|
|
24
|
+
SET metadata = metadata
|
|
25
|
+
|| jsonb_build_object('status', 'waiting',
|
|
26
|
+
'status_legacy', metadata ->> 'status')
|
|
27
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
28
|
+
AND metadata ->> 'status' IN ('waiting_human', 'in_review',
|
|
29
|
+
'mr-open-awaiting-human-merge',
|
|
30
|
+
'contained-pending-permanent-fix', 'partial');
|
|
31
|
+
|
|
32
|
+
-- 중복 표시는 병합 이전 시대의 흔적이다. 사유를 달아 rejected 로 닫는다 —
|
|
33
|
+
-- 실제 병합은 215 부터 포착 시점에 일어난다.
|
|
34
|
+
UPDATE semo_ops.knowledge_base
|
|
35
|
+
SET metadata = metadata
|
|
36
|
+
|| jsonb_build_object('status', 'rejected',
|
|
37
|
+
'status_legacy', metadata ->> 'status',
|
|
38
|
+
'rejected_reason',
|
|
39
|
+
'중복으로 표시된 병합 이전 기록 (마이그레이션 212)')
|
|
40
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
41
|
+
AND metadata ->> 'status' IN ('duplicate', 'duplicate-closed');
|
|
42
|
+
|
|
43
|
+
-- status 키 자체가 없는 행이 7건 있었다. 부재는 open 이다.
|
|
44
|
+
UPDATE semo_ops.knowledge_base
|
|
45
|
+
SET metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('status', 'open')
|
|
46
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
47
|
+
AND (metadata IS NULL OR NOT metadata ? 'status');
|
|
48
|
+
|
|
49
|
+
-- 사유 없이 rejected 인 행이 남아 있으면 트리거가 이후 모든 수정을 막는다. 지금 채운다.
|
|
50
|
+
UPDATE semo_ops.knowledge_base
|
|
51
|
+
SET metadata = metadata || jsonb_build_object('rejected_reason',
|
|
52
|
+
'사유 미기록 (마이그레이션 212 이전)')
|
|
53
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
54
|
+
AND metadata ->> 'status' = 'rejected'
|
|
55
|
+
AND coalesce(btrim(metadata ->> 'rejected_reason'), '') = '';
|
|
56
|
+
|
|
57
|
+
-- ── 2. 어휘 강제 ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
CREATE OR REPLACE FUNCTION semo_ops.feedback_status_guard()
|
|
60
|
+
RETURNS trigger
|
|
61
|
+
LANGUAGE plpgsql
|
|
62
|
+
SET search_path TO 'pg_catalog'
|
|
63
|
+
AS $function$
|
|
64
|
+
DECLARE
|
|
65
|
+
v_status text;
|
|
66
|
+
BEGIN
|
|
67
|
+
NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb);
|
|
68
|
+
v_status := NEW.metadata ->> 'status';
|
|
69
|
+
|
|
70
|
+
-- 상태를 안 주면 open 이다. 거절하지 않는 이유: 포착 경로가 상태를 생각하지 않아도
|
|
71
|
+
-- 되게 하려는 것이고, 그 기본값은 이미 record_feedback 의 계약이다.
|
|
72
|
+
IF v_status IS NULL THEN
|
|
73
|
+
NEW.metadata := NEW.metadata || jsonb_build_object('status', 'open');
|
|
74
|
+
v_status := 'open';
|
|
75
|
+
END IF;
|
|
76
|
+
|
|
77
|
+
IF v_status NOT IN ('open', 'rejected', 'fixed', 'waiting') THEN
|
|
78
|
+
RAISE EXCEPTION
|
|
79
|
+
'feedback status % is outside the vocabulary (open/rejected/fixed/waiting)', v_status
|
|
80
|
+
USING ERRCODE = '22023';
|
|
81
|
+
END IF;
|
|
82
|
+
|
|
83
|
+
-- 사유 없는 기각은 기각이 아니라 유실이다. 자율 기각을 켜는 이상 이건 강제여야 한다.
|
|
84
|
+
IF v_status = 'rejected'
|
|
85
|
+
AND coalesce(btrim(NEW.metadata ->> 'rejected_reason'), '') = '' THEN
|
|
86
|
+
RAISE EXCEPTION
|
|
87
|
+
'a rejected feedback requires metadata.rejected_reason'
|
|
88
|
+
USING ERRCODE = '22023';
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
RETURN NEW;
|
|
92
|
+
END;
|
|
93
|
+
$function$;
|
|
94
|
+
|
|
95
|
+
DROP TRIGGER IF EXISTS feedback_status_guard ON semo_ops.knowledge_base;
|
|
96
|
+
CREATE TRIGGER feedback_status_guard
|
|
97
|
+
BEFORE INSERT OR UPDATE ON semo_ops.knowledge_base
|
|
98
|
+
FOR EACH ROW
|
|
99
|
+
WHEN (NEW.domain = 'semicolon' AND NEW.key = 'feedback')
|
|
100
|
+
EXECUTE FUNCTION semo_ops.feedback_status_guard();
|
|
101
|
+
|
|
102
|
+
-- ── 3. 정규화가 끝났는지 확인한다 ────────────────────────────────────
|
|
103
|
+
-- 조용히 남은 값 하나가 나중에 어떤 쓰기를 막는다. 지금 큰 소리로 실패하는 편이 낫다.
|
|
104
|
+
DO $$
|
|
105
|
+
DECLARE
|
|
106
|
+
v_stray int;
|
|
107
|
+
BEGIN
|
|
108
|
+
SELECT count(*) INTO v_stray
|
|
109
|
+
FROM semo_ops.knowledge_base
|
|
110
|
+
WHERE domain = 'semicolon' AND key = 'feedback'
|
|
111
|
+
AND coalesce(metadata ->> 'status', 'open')
|
|
112
|
+
NOT IN ('open', 'rejected', 'fixed', 'waiting');
|
|
113
|
+
IF v_stray > 0 THEN
|
|
114
|
+
RAISE EXCEPTION 'the feedback status vocabulary still has % stray rows', v_stray;
|
|
115
|
+
END IF;
|
|
116
|
+
END $$;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- 213_feedback_policy_and_events.sql
|
|
2
|
+
--
|
|
3
|
+
-- 재발 판정의 두 축을 만든다: 문턱을 담는 정책 한 행과, 그 문턱을 나중에 근거로
|
|
4
|
+
-- 고칠 수 있게 하는 이벤트 원장.
|
|
5
|
+
--
|
|
6
|
+
-- 왜 상수가 아니라 테이블인가. 병합 거리 0.10 은 실측이다(2026-08-25, 22쌍이 제목까지
|
|
7
|
+
-- 같은 재보고였다). 승격 문턱은 실측이 아니라 추측이다. 추측을 함수 안에 박으면
|
|
8
|
+
-- 고치는 데 마이그레이션이 필요하고, 무엇보다 **그 값이 맞는지 볼 방법이 없다.**
|
|
9
|
+
--
|
|
10
|
+
-- 이 테이블 읽기는 루프를 죽인 실패 모드와 다르다. 그건 HTTP 설정 조회가 403 을 내고
|
|
11
|
+
-- 삼켜진 것이었다. 같은 트랜잭션 안의 로컬 테이블 읽기는 되거나, 쓰기 전체가 실패한다.
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS semo_ops.feedback_policy (
|
|
14
|
+
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
|
15
|
+
merge_distance numeric(4,3) NOT NULL CHECK (merge_distance > 0),
|
|
16
|
+
link_distance numeric(4,3) NOT NULL CHECK (link_distance > 0),
|
|
17
|
+
promote_occurrences int NOT NULL CHECK (promote_occurrences >= 1),
|
|
18
|
+
promote_distinct_hosts int NOT NULL CHECK (promote_distinct_hosts >= 1),
|
|
19
|
+
rationale text NOT NULL,
|
|
20
|
+
updated_by text NOT NULL,
|
|
21
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
22
|
+
CHECK (merge_distance < link_distance)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
INSERT INTO semo_ops.feedback_policy
|
|
26
|
+
(id, merge_distance, link_distance, promote_occurrences, promote_distinct_hosts,
|
|
27
|
+
rationale, updated_by)
|
|
28
|
+
VALUES
|
|
29
|
+
(1, 0.100, 0.180, 2, 2,
|
|
30
|
+
'병합 거리 0.10 은 2026-08-25 실측이다 — 그 아래 22쌍은 제목까지 같은 재보고였고, '
|
|
31
|
+
'본문이 조금 달라 내용 해시가 갈리는 바람에 별건이 됐다. 0.18 은 그 위 구간에 '
|
|
32
|
+
'서비스가 다른 건과 같은 파일 건이 섞여 거리로 못 가른다는 관찰에서 온 경계다. '
|
|
33
|
+
'승격 문턱 2/2 는 추측이며, 되돌릴 수 있는 쪽으로 기울였다 — 과승격은 다이제스트에 '
|
|
34
|
+
'숫자로 보이지만 과소승격은 침묵하고, 침묵이 성공처럼 보이는 것이 이 파이프라인이 '
|
|
35
|
+
'2주를 잃은 실패 모드다. feedback_merge_events 가 몇 주 쌓이면 그 분포로 다시 정한다.',
|
|
36
|
+
'reus')
|
|
37
|
+
ON CONFLICT (id) DO NOTHING;
|
|
38
|
+
|
|
39
|
+
COMMENT ON TABLE semo_ops.feedback_policy IS
|
|
40
|
+
'피드백 재발 판정 문턱. 정확히 한 행. 행이 없으면 record_feedback 이 기본값으로 '
|
|
41
|
+
'떨어지지 않고 예외를 던진다 — 부재는 기본값이 아니다.';
|
|
42
|
+
|
|
43
|
+
-- 원장. 덧붙이기만 하고, 문턱을 다시 정할 때 읽는다.
|
|
44
|
+
CREATE TABLE IF NOT EXISTS semo_ops.feedback_merge_events (
|
|
45
|
+
event_id bigserial PRIMARY KEY,
|
|
46
|
+
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
47
|
+
kind text NOT NULL CHECK (kind IN ('merged', 'linked', 'promoted', 'regressed')),
|
|
48
|
+
-- 병합돼 사라진 쪽(생기지 않은 slug)과 남은 쪽.
|
|
49
|
+
source_slug text NOT NULL,
|
|
50
|
+
target_slug text,
|
|
51
|
+
distance numeric(6,5),
|
|
52
|
+
occurrences_after int,
|
|
53
|
+
distinct_hosts_after int,
|
|
54
|
+
-- 반응을 정한 것은 대상의 상태였다. 나중에 "왜 승격됐나" 를 이 열로 답한다.
|
|
55
|
+
target_status_before text,
|
|
56
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS feedback_merge_events_occurred_idx
|
|
60
|
+
ON semo_ops.feedback_merge_events (occurred_at DESC);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS feedback_merge_events_target_idx
|
|
62
|
+
ON semo_ops.feedback_merge_events (target_slug);
|
|
63
|
+
|
|
64
|
+
COMMENT ON TABLE semo_ops.feedback_merge_events IS
|
|
65
|
+
'병합·연결·승격·회귀 한 줄씩. 승격 문턱을 추측이 아니라 분포에서 정하기 위한 원장.';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
-- 214_resolve_feedback.sql
|
|
2
|
+
--
|
|
3
|
+
-- 피드백의 완수는 두 곳에 있다: KB 레코드 `semicolon/feedback/<slug>` 의
|
|
4
|
+
-- metadata.status 와, 짝이 되는 `action_items(source_kind='feedback')` 행.
|
|
5
|
+
--
|
|
6
|
+
-- 2026-08-25 실측: done 에 도달한 액션아이템 4건 중 **2건이 KB 에서 여전히 open** 이었다.
|
|
7
|
+
-- 닫는 경로가 둘인데 서로를 모르기 때문이다. 이 함수가 정규 경로가 되고, 트리거가
|
|
8
|
+
-- 정규 경로를 안 타는 쪽(`semo action-items done`)을 받친다.
|
|
9
|
+
|
|
10
|
+
CREATE OR REPLACE FUNCTION semo_ops.resolve_feedback(
|
|
11
|
+
p_slug text,
|
|
12
|
+
p_status text,
|
|
13
|
+
p_reason text DEFAULT NULL,
|
|
14
|
+
p_fix_ref text DEFAULT NULL,
|
|
15
|
+
p_actor text DEFAULT NULL
|
|
16
|
+
)
|
|
17
|
+
RETURNS TABLE(kb_id bigint, action_item_id uuid, status text)
|
|
18
|
+
LANGUAGE plpgsql
|
|
19
|
+
SECURITY DEFINER
|
|
20
|
+
SET search_path TO 'pg_catalog'
|
|
21
|
+
AS $function$
|
|
22
|
+
DECLARE
|
|
23
|
+
v_kb_id bigint;
|
|
24
|
+
v_ai_id uuid;
|
|
25
|
+
v_ai_status text;
|
|
26
|
+
v_meta jsonb;
|
|
27
|
+
BEGIN
|
|
28
|
+
IF p_status NOT IN ('open', 'rejected', 'fixed', 'waiting') THEN
|
|
29
|
+
RAISE EXCEPTION 'feedback status % is outside the vocabulary', p_status
|
|
30
|
+
USING ERRCODE = '22023';
|
|
31
|
+
END IF;
|
|
32
|
+
|
|
33
|
+
-- 212 의 트리거도 이걸 막지만, 여기서 먼저 막아야 액션아이템을 건드리기 전에 멈춘다.
|
|
34
|
+
IF p_status = 'rejected' AND coalesce(btrim(p_reason), '') = '' THEN
|
|
35
|
+
RAISE EXCEPTION 'a rejected feedback requires a reason' USING ERRCODE = '22023';
|
|
36
|
+
END IF;
|
|
37
|
+
|
|
38
|
+
v_ai_status := CASE p_status
|
|
39
|
+
WHEN 'fixed' THEN 'done'
|
|
40
|
+
WHEN 'rejected' THEN 'cancelled'
|
|
41
|
+
WHEN 'waiting' THEN 'blocked'
|
|
42
|
+
ELSE 'open'
|
|
43
|
+
END;
|
|
44
|
+
|
|
45
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
|
|
46
|
+
|
|
47
|
+
v_meta := jsonb_build_object('status', p_status)
|
|
48
|
+
|| CASE WHEN p_reason IS NULL THEN '{}'::jsonb
|
|
49
|
+
ELSE jsonb_build_object('rejected_reason', p_reason) END
|
|
50
|
+
|| CASE WHEN p_fix_ref IS NULL THEN '{}'::jsonb
|
|
51
|
+
ELSE jsonb_build_object('fix_ref', p_fix_ref) END
|
|
52
|
+
|| CASE WHEN p_actor IS NULL THEN '{}'::jsonb
|
|
53
|
+
ELSE jsonb_build_object('resolved_by', p_actor) END
|
|
54
|
+
|| jsonb_build_object('resolved_at', to_char(clock_timestamp(),
|
|
55
|
+
'YYYY-MM-DD"T"HH24:MI:SSOF'));
|
|
56
|
+
|
|
57
|
+
UPDATE semo_ops.knowledge_base AS kb
|
|
58
|
+
SET metadata = coalesce(kb.metadata, '{}'::jsonb) || v_meta,
|
|
59
|
+
updated_at = clock_timestamp()
|
|
60
|
+
WHERE kb.domain = 'semicolon' AND kb.key = 'feedback' AND kb.sub_key = p_slug
|
|
61
|
+
RETURNING kb.kb_id INTO v_kb_id;
|
|
62
|
+
|
|
63
|
+
IF v_kb_id IS NULL THEN
|
|
64
|
+
RAISE EXCEPTION 'no feedback record at semicolon/feedback/%', p_slug
|
|
65
|
+
USING ERRCODE = 'P0002';
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
68
|
+
-- 액션아이템은 없을 수도 있다 — 2026-08-10 이전 656건에는 아예 만들어지지 않았다.
|
|
69
|
+
-- 없는 것은 오류가 아니다. 그 경우 action_item_id 는 NULL 로 돌려준다.
|
|
70
|
+
UPDATE semo_ops.action_items AS ai
|
|
71
|
+
SET status = v_ai_status,
|
|
72
|
+
completed_at = CASE WHEN v_ai_status IN ('done', 'cancelled')
|
|
73
|
+
THEN clock_timestamp() ELSE NULL END,
|
|
74
|
+
updated_at = clock_timestamp()
|
|
75
|
+
WHERE ai.source_kind = 'feedback' AND ai.source_ref = p_slug
|
|
76
|
+
AND ai.status IS DISTINCT FROM v_ai_status
|
|
77
|
+
RETURNING ai.action_item_id INTO v_ai_id;
|
|
78
|
+
|
|
79
|
+
RETURN QUERY SELECT v_kb_id, v_ai_id, p_status;
|
|
80
|
+
END;
|
|
81
|
+
$function$;
|
|
82
|
+
|
|
83
|
+
COMMENT ON FUNCTION semo_ops.resolve_feedback(text, text, text, text, text) IS
|
|
84
|
+
'KB 레코드와 짝 액션아이템의 상태를 한 트랜잭션에서 맞춘다. 대시보드 뒤집기와 '
|
|
85
|
+
'해소 루프의 정규 경로.';
|
|
86
|
+
|
|
87
|
+
-- ── 안전망 ────────────────────────────────────────────────────────────
|
|
88
|
+
-- `semo action-items done` 은 위 함수를 타지 않는다. 그 경로로 닫힌 것을 KB 에 옮긴다.
|
|
89
|
+
-- 반대 방향 트리거는 두지 않는다 — KB 쪽(212)은 검증만 하고, 양방향이면 서로를 부른다.
|
|
90
|
+
|
|
91
|
+
-- SECURITY DEFINER: 이 트리거는 action_items 를 직접 UPDATE 하는 어떤 롤에서도 발동한다.
|
|
92
|
+
-- sc_ops_records 처럼 action_items 는 쓰지만 knowledge_base 는 못 쓰는 롤이 있으면
|
|
93
|
+
-- (게이트웨이 SECURITY DEFINER 경로를 거치지 않고 직접 UPDATE 하는 경우), INVOKER 트리거는
|
|
94
|
+
-- KB UPDATE 에서 permission denied 를 내고 그 예외가 원래의 action_items UPDATE 전체를
|
|
95
|
+
-- 롤백시킨다 — 안전망이 안전망이 아니라 걸림돌이 된다. search_path 고정은 이미 되어 있고
|
|
96
|
+
-- 이 함수가 건드리는 행은 domain='semicolon' AND key='feedback' AND sub_key=NEW.source_ref
|
|
97
|
+
-- 하나로 좁혀지므로 반경은 제한적이다.
|
|
98
|
+
CREATE OR REPLACE FUNCTION semo_ops.feedback_action_item_sync()
|
|
99
|
+
RETURNS trigger
|
|
100
|
+
LANGUAGE plpgsql
|
|
101
|
+
SECURITY DEFINER
|
|
102
|
+
SET search_path TO 'pg_catalog'
|
|
103
|
+
AS $function$
|
|
104
|
+
DECLARE
|
|
105
|
+
v_status text;
|
|
106
|
+
BEGIN
|
|
107
|
+
v_status := CASE NEW.status
|
|
108
|
+
WHEN 'done' THEN 'fixed'
|
|
109
|
+
WHEN 'cancelled' THEN 'rejected'
|
|
110
|
+
ELSE NULL
|
|
111
|
+
END;
|
|
112
|
+
IF v_status IS NULL THEN
|
|
113
|
+
RETURN NEW;
|
|
114
|
+
END IF;
|
|
115
|
+
|
|
116
|
+
UPDATE semo_ops.knowledge_base AS kb
|
|
117
|
+
SET metadata = coalesce(kb.metadata, '{}'::jsonb)
|
|
118
|
+
|| jsonb_build_object('status', v_status)
|
|
119
|
+
-- cancelled 는 사유 없는 rejected 가 될 수 있다. 212 의 트리거가
|
|
120
|
+
-- 그걸 거절하므로, 액션아이템 경로로 닫힌 것에는 출처를 사유로 단다.
|
|
121
|
+
|| CASE WHEN v_status = 'rejected'
|
|
122
|
+
AND coalesce(btrim(kb.metadata ->> 'rejected_reason'), '') = ''
|
|
123
|
+
THEN jsonb_build_object('rejected_reason',
|
|
124
|
+
'액션아이템이 cancelled 로 닫혔다 (source_ref=' || NEW.source_ref || ')')
|
|
125
|
+
ELSE '{}'::jsonb END,
|
|
126
|
+
updated_at = clock_timestamp()
|
|
127
|
+
WHERE kb.domain = 'semicolon' AND kb.key = 'feedback'
|
|
128
|
+
AND kb.sub_key = NEW.source_ref
|
|
129
|
+
AND coalesce(kb.metadata ->> 'status', 'open') IS DISTINCT FROM v_status;
|
|
130
|
+
|
|
131
|
+
RETURN NEW;
|
|
132
|
+
END;
|
|
133
|
+
$function$;
|
|
134
|
+
|
|
135
|
+
DROP TRIGGER IF EXISTS feedback_action_item_sync ON semo_ops.action_items;
|
|
136
|
+
CREATE TRIGGER feedback_action_item_sync
|
|
137
|
+
AFTER UPDATE OF status ON semo_ops.action_items
|
|
138
|
+
FOR EACH ROW
|
|
139
|
+
WHEN (NEW.source_kind = 'feedback' AND NEW.status IS DISTINCT FROM OLD.status)
|
|
140
|
+
EXECUTE FUNCTION semo_ops.feedback_action_item_sync();
|