@fcg-labs/cx-agent-hook 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,14 @@
1
+ # AGENTS.md — @fcg-labs/cx-agent-hook
2
+
3
+ 이 패키지를 고객사 관리자 화면(CMS)에 붙이거나 올리는 작업을 맡은 에이전트는
4
+ **`skills/cx-agent-hook-setup/SKILL.md`** 를 먼저 읽고 그 절차(실측 → 결정 규칙 →
5
+ 6단계 → 완료 체크리스트)를 따른다. 인용해도 되는 API 이름은 같은 폴더의
6
+ `reference.md` 가 정본이며 `test/skills.test.js` 가 실제 export 와 대조한다.
7
+
8
+ - 셋업 = 답변 초안 패널 **+ 처리 카드(`adminApi`)** 까지. 관리자 API 가 있는 프로젝트에서
9
+ `adminApi` 를 빼먹으면 미완이다.
10
+ - 서버 코드·새 엔드포인트·서명·토큰 발급을 만들지 않는다. 비밀을 저장소에 넣지 않는다.
11
+ - 프로젝트 성격(프레임워크·번들러·경로 별칭·요청 헬퍼 시그니처)에 맞춰 코드를 조정하되,
12
+ 스킬에 없는 API 를 지어내지 않는다.
13
+
14
+ 설치: `npx cx-agent-hook skills install [--all]` (프로젝트의 `.claude/skills/`·`.cursor/rules/`·`AGENTS.md`).
package/MIGRATION.md CHANGED
@@ -1,3 +1,56 @@
1
+ # 0.4.0 → 0.5.0 마이그레이션
2
+
3
+ ## 요약 — 처리 액션 표면만 바뀐다 (0.3.0 표면은 그대로)
4
+
5
+ 0.4.0 의 처리 액션은 "허브가 고객사 서버(실행기)를 부른다"였다 — 고객사에게 어댑터
6
+ 서버·서명 검증·단명 토큰을 요구했다. 0.5.0 은 **실행기가 CMS 자신**이다: 브라우저가
7
+ CMS 의 기존 관리자 API 를 CMS 세션으로 부르고, 허브는 원장(선점·결과·잠금)만이다.
8
+ 0.4.0 을 실배선한 소비처는 없었다(CMS 는 0.3.0). `createCxAgent`·`session`·어댑터·
9
+ 클래스명은 전부 그대로 (기존 시험 107건 무수정 통과, 액션 시험 17건 재작성).
10
+
11
+ ## 깨지는 것 (0.4.0 액션 표면만)
12
+
13
+ - `createCxAgent({ actorAssertion })` **삭제** → `createCxAgent({ adminApi })`.
14
+ `adminApi = { request, endpoints, map?, onAuthExpired? }` — CMS 에 **이미 있는**
15
+ 요청 헬퍼(예: `TempAdminApi.request`)와 엔드포인트 맵(예: `EndPoint`)의 참조.
16
+ - `session.reconcileAction(requestId)` **삭제** → 조회 대상(고객사 서버 lookup)이 없다.
17
+ 대신 `session.resendResult(requestId)`(미보고 결과 재보고, 재실행 아님) ·
18
+ `session.flushUnreportedActions()` · `session.refreshActionLocks()`.
19
+ - `ActionResult` 에서 `actorAttested`·`auditRef`·`result` 삭제(고객사 서버 응답 개념),
20
+ `reported`·`targetKey` 추가. `state` 에 `received`(선점 완료) 추가.
21
+ - 뷰: `ACTION_CLS.reconcile` → `locked`·`resend`; `ActionOfferItemView.canReconcile/
22
+ reconcileLabel` → `canResend/resendLabel` + `available/locked/lockedText/unavailableText`.
23
+ 트리 action `"reconcile"` → `"resend"`. 로케일 키 `ui_action_reconcile` → `ui_action_resend`,
24
+ 신설 `ui_action_locked`·`ui_action_unavailable`·`ui_action_unreported`.
25
+
26
+ ## 새로 생긴 것
27
+
28
+ - `agent.candidatesReady` / `agent.publishActionCandidates({force})` — 초기화 시 엔드포인트
29
+ 맵의 키·메서드·경로 템플릿(호스트 없음)을 허브에 1회 발행(로컬 지문 마커로 재발행 0).
30
+ - `offer.execution`(카탈로그 실행 사양 원문)·`offer.target_key`, `session.offers` 가
31
+ `available`(이 CMS 가 API 를 아는가)·`locked`(같은 대상·입력 최근 성공)를 병합.
32
+ - `actions.js` 순수 함수: `buildCandidateSnapshot`·`resolveExecution`·`judgeResponse`·
33
+ `executeViaAdminApi` (agent 에서 재수출).
34
+ - 어댑터 처리 카드: `/react` `ActionOffersPanel` · `/vue` `ActionOffersPanel` · `/element`
35
+ `<cx-action-offers>`(`defineCxActionOffers`, 이벤트 `cx-action-result`) + `styles.css` `.fcx-act-*`.
36
+ 0.4.0 의 "소비처가 actionOffersTree 로 직접 그린다"는 더 이상 필요 없다(헤드리스는 그대로 가능).
37
+
38
+ ## 에이전트 스킬 (신설)
39
+
40
+ 패키지에 `skills/cx-agent-hook-setup/`(SKILL.md·reference.md)과 `npx cx-agent-hook skills install`
41
+ CLI 가 들어 있다. 개발자가 자기 코딩 에이전트에게 "cx-agent-hook 셋업 도와줘" 라고만 해도
42
+ 실측→adminApi 포함 배선→처리 카드→확인까지 하게 만드는 절차다. `AGENTS.md` 도 같은 곳을 가리킨다.
43
+
44
+ ## 행동 계약
45
+
46
+ - 선점(①) 없이는 CMS 를 부르지 않는다 (원장 없는 실행 금지). CMS 호출(②)·보고(③)는
47
+ **재시도 0**. 선점만 미도달 확정(404) 시 1회 재전송.
48
+ - `unknown`(전송 후 결과 불명·5xx)은 재실행 버튼이 없다 — 카드가 "확인 필요"로 남고,
49
+ 공장 관측의 "확인 필요 24h+" 칩이 정본. 미보고는 `resendResult` 로만 수렴(재실행 0).
50
+ - 잠금은 편의다: 정본은 허브 원장과 CMS 자기 API 의 정책. 다른 입력이면 허용.
51
+
52
+ ---
53
+
1
54
  # 0.3.0 → 0.4.0 마이그레이션
2
55
 
3
56
  ## 요약 — 깨지는 것은 없다
package/README.md CHANGED
@@ -30,6 +30,22 @@ npm install @fcg-labs/cx-agent-hook
30
30
  React·Vue 어댑터를 쓸 때만 그 프레임워크가 필요하다 (optional peer). 전송 계층만
31
31
  쓰면 의존성은 0 이다.
32
32
 
33
+ ### AI 코딩 에이전트에게 맡기기 (권장)
34
+
35
+ 패키지 안에 **에이전트 스킬**이 들어 있다 — 설치→환경변수→훅(`adminApi` 포함)→답변
36
+ 슬롯→처리 카드→확인까지, 프로젝트를 먼저 실측하고(프레임워크·번들러·기존 관리자 API
37
+ 헬퍼·엔드포인트 맵·세션 만료 처리·문의 화면) 그 성격에 맞춰 배선하는 절차와, 인용해도
38
+ 되는 API 이름 표(reference.md, 시험이 실제 export 와 대조)다.
39
+
40
+ ```bash
41
+ npx cx-agent-hook skills install # Claude Code: .claude/skills/cx-agent-hook-setup/
42
+ npx cx-agent-hook skills install --all # + Cursor 규칙(.cursor/rules) + AGENTS.md 한 줄
43
+ npx cx-agent-hook skills path # 그냥 "이 파일 읽고 따라줘" 라고 줄 때
44
+ ```
45
+ 그 뒤 에이전트에게 **"cx-agent-hook 셋업 도와줘"** — 답변 패널만이 아니라 처리 카드
46
+ (adminApi)까지가 셋업이라고 스킬이 못박는다. 사람이 읽을 때는 아래 절과
47
+ `skills/cx-agent-hook-setup/SKILL.md` 가 같은 내용이다.
48
+
33
49
  ## 0.3.0 — 문의별 세션 (권장 표면)
34
50
 
35
51
  `createCxHook` 은 그대로 동작한다(호환층). 문의별 초안 상태·영속이 필요한
@@ -57,33 +73,56 @@ CSS 는 사용처 `var(--fcx-*, 폴백)` 직참조라 소비처 `:root` 한 줄
57
73
  바꾼다. 다크는 조상에 `data-fcx-theme="dark"`. headless 2단계(스타일 0 /
58
74
  UI 0 — `aiSuggestTree`)도 공식 표면이다.
59
75
 
60
- ## 0.4.0 — 처리 액션 (말에서 행으로, 선택 표면)
76
+ ## 0.5.0 — 처리 액션 (브라우저 실행기: 실행기는 CMS 자신)
61
77
 
62
78
  답변 제안 옆에 **처리 선택지**가 실릴 수 있다 (`payload.suggested_actions`). 이 SDK 는
63
- 그 선택지를 보관·표시하고, 상담사 확인 뒤 허브에 실행을 요청하고, 결과 3상태를
64
- 보여준다. **실행기·권한·정책은 고객사 서버가 소유**한다 SDK 계약(`actions/1`)의
65
- 클라이언트일 뿐이다.
79
+ 그 선택지를 보관·표시하고, 상담사 확인 뒤 **CMS 기존 관리자 API 를 CMS 세션으로**
80
+ 불러 처리하고, 결과를 허브 원장에 보고한다. 허브는 어떤 고객사 서버도 부르지 않는다.
81
+ **고객사 개발자가 새로 짜는 코드는 없다** — 이미 있는 요청 헬퍼와 엔드포인트 맵의
82
+ 참조 두 개를 옵션으로 넘길 뿐이다.
66
83
 
67
84
  ```js
85
+ import TempAdminApi, { EndPoint } from "@/constant/TempAdminApi"; // CMS 에 이미 있는 것
86
+ import TempAdminApiMap from "@/constant/TempAdminApiMap";
68
87
  const agent = createCxAgent({
69
88
  baseUrl, token, domain,
70
- // 고객사가 발행하는 단명·목적 한정 행위자 토큰. 없으면 액션 표면은 닫힌다
71
- // (선택지는 오되 실행 버튼이 없다). 전권 세션 토큰을 넘기지 말 것.
72
- actorAssertion: () => fetchMyActionToken(),
89
+ adminApi: { request: TempAdminApi.request, endpoints: EndPoint, map: TempAdminApiMap,
90
+ onAuthExpired: handleAuthExpiredResponse }, // 없으면 액션 표면은 닫힌다
73
91
  });
74
92
  const session = agent.session(inquiryId);
75
93
  session.attachActionUi({ setOffers: (offers, pending) => render(offers, pending) });
76
94
  // 사람 확인(확인 문구·고위험은 2단) 뒤에만:
77
95
  const r = await session.executeAction(offer, { actorClaimed: agentDisplayName });
78
- // r.state: succeeded | failed | unknown — unknown 은 "다시 확인"(reconcile) 만, 재실행 없음
79
- await session.reconcileAction(r.requestId);
96
+ // r.state: succeeded | failed | unknown — unknown 은 재실행 없음("확인 필요"로 남는다)
97
+ // 보고가 안 됐으면(r.reported=false) session.resendResult(r.requestId) — 재실행 아님
98
+ ```
99
+
100
+ 3단: ① 허브 선점(request_id ULID, 전송 전 영속, 재시도 0 — 네트워크 오류면 상태를
101
+ 묻고 기록이 없을 때만 같은 request_id 로 1회 재전송) → ② `adminApi.request` 1회 —
102
+ 카탈로그의 실행 사양(`offer.execution`)이 `$name` 을 `params_bound` 로 치환하고
103
+ `response_rule` 이 성공/실패를 읽는다 → ③ 결과 보고(허브가 상태 단조·멱등 보장).
104
+
105
+ 초기화 시 SDK 가 엔드포인트 맵의 **키·메서드·경로 템플릿(호스트 없음)** 을 허브에
106
+ 1회 발행한다 — 공장 시스템 탭의 "이 CMS 가 이미 하는 처리" 목록이 여기서 온다.
107
+ 같은 지문이면 네트워크 0(`agent.candidatesReady`, `publishActionCandidates({force})`).
108
+
109
+ 처리 카드 슬롯은 어댑터 한 줄이다 — React `ActionOffersPanel`(`/react`), Vue `ActionOffersPanel`
110
+ (`/vue`), 그 밖은 `<cx-action-offers>`(`/element`, `defineCxActionOffers()`), 스타일은 같은
111
+ `styles.css`(`.fcx-act-*`). 확인 2단·고위험 체크·재실행 금지·미보고 재보고 규약을 컴포넌트가 갖는다:
112
+
113
+ ```jsx
114
+ import { ActionOffersPanel } from "@fcg-labs/cx-agent-hook/react";
115
+ <ActionOffersPanel session={agent.session(csId)} agent={agent} actorClaimed={answerWriter}
116
+ onResult={(r) => r.state === "succeeded" && refresh()} />
80
117
  ```
81
118
 
82
- 계약 요점: 요청은 request_id(ULID)로 멱등이고 SDK **재시도하지 않는다**
83
- 네트워크 오류면 상태를 묻고, 허브에 기록이 없을 때만 같은 request_id 1회
84
- 재전송한다. 눌렀던 처리는 `session.pendingActions()` 새로고침 뒤에도 안다
85
- (정본은 허브 원장). 헤드리스 뷰: `actionOffersView` / `actionOffersTree`(`view.js`),
86
- 클래스 `fcx-act-*`, 문구는 `ui_action_*` 로케일 키.
119
+ 카드 상태: `locked`(같은 대상·같은 입력의 최근 성공 "이미 처리됨 · 시각 · 행위자")
120
+ · `unavailable`(이 CMS API 모름 fail-closed) · 실행 중 · 완료 · 실패(재시도
121
+ 가능) · 확인 필요(unknown) · 미보고(결과 다시 보고). 눌렀던 처리는
122
+ `session.pendingActions()` 로 새로고침 뒤에도 안다(정본은 허브 원장). 헤드리스 뷰:
123
+ `actionOffersView` / `actionOffersTree`(`view.js`), 클래스 `fcx-act-*`, 문구는
124
+ `ui_action_*` 로케일 키. 순수 함수 `buildCandidateSnapshot` · `resolveExecution` ·
125
+ `judgeResponse` · `executeViaAdminApi` (`actions.js`) 는 시험·커스텀 경로용으로 공개.
87
126
 
88
127
  ## 지원 프레임워크
89
128
 
package/actions.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 처리 액션 — 브라우저 실행기 프로파일(actions/1 프로파일 B)의 순수 함수.
3
+ *
4
+ * 실행기는 CMS 자신이다: 이 SDK 는 CMS 가 주입한 `adminApi`(요청 헬퍼 + 엔드포인트
5
+ * 맵)로 **CMS 의 기존 관리자 API 를 CMS 세션으로** 부르고, 결과를 허브 원장에
6
+ * 보고한다. 허브는 어떤 고객사 서버도 부르지 않는다. 고객사 개발자가 새로 짜는
7
+ * 코드는 0 — 훅 옵션 한 줄(`adminApi: { request, endpoints, map, onAuthExpired }`)뿐.
8
+ *
9
+ * 여기 있는 것 전부가 결정적·의존성 0 이라 시험이 쉽고, 어느 CMS 든 같다:
10
+ * buildCandidateSnapshot — 엔드포인트 맵 → "이 CMS 가 이미 하는 처리" 스냅샷 (호스트 제거)
11
+ * resolveExecution — 카탈로그 실행 사양의 `$name` 을 offer.params_bound 로 치환
12
+ * judgeResponse — 응답 → succeeded | failed | unknown (카탈로그 response_rule)
13
+ * executeViaAdminApi — 위 둘을 이어 CMS API 1회 호출 (재시도 0)
14
+ */
15
+
16
+ const METHODS = ["get", "post", "put", "patch", "delete"];
17
+
18
+ /** 원점(스킴+호스트) 제거 — `http://h/x/:id` → `/x/:id`, 상대 경로면 그대로. */
19
+ export function stripOrigin(url) {
20
+ const s = String(url || "").trim();
21
+ const m = /^[a-z][a-z0-9+.-]*:\/\/[^/]*(\/.*)?$/i.exec(s);
22
+ if (m) return m[1] || "/";
23
+ return s.startsWith("/") ? s : (s ? "/" + s : "");
24
+ }
25
+
26
+ /** 결정적 64bit 지문(FNV-1a ×2) — 스냅샷 멱등 키. 암호학적 용도 아님. */
27
+ export function fingerprint(text) {
28
+ const s = String(text);
29
+ let h1 = 0x811c9dc5, h2 = 0x01000193 ^ 0x9747b28c;
30
+ for (let i = 0; i < s.length; i++) {
31
+ const c = s.charCodeAt(i);
32
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
33
+ h2 = Math.imul(h2 ^ c, 0x01000193) >>> 0;
34
+ }
35
+ return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
36
+ }
37
+
38
+ /**
39
+ * 엔드포인트 맵 → 후보 스냅샷. `endpoints` 는 {KEY: urlTemplate}, `map` 은
40
+ * {method: {urlTemplate: {}}}(화이트리스트; 없으면 메서드 []). 키 정렬·호스트 제거.
41
+ * @returns {{snapshotHash:string, items:Array<{endpoint_key:string, methods:string[], path_template:string}>}}
42
+ */
43
+ export function buildCandidateSnapshot(adminApi) {
44
+ const endpoints = (adminApi && adminApi.endpoints && typeof adminApi.endpoints === "object") ? adminApi.endpoints : {};
45
+ const map = (adminApi && adminApi.map && typeof adminApi.map === "object") ? adminApi.map : {};
46
+ const items = [];
47
+ for (const key of Object.keys(endpoints).sort()) {
48
+ const url = endpoints[key];
49
+ if (typeof url !== "string" || !url) continue;
50
+ const methods = METHODS.filter((m) => map[m] && typeof map[m] === "object" && map[m][url] !== undefined);
51
+ items.push({ endpoint_key: key, methods, path_template: stripOrigin(url) });
52
+ }
53
+ return { snapshotHash: fingerprint(JSON.stringify(items)), items };
54
+ }
55
+
56
+ function subst(mapping, bound, missing) {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(mapping || {})) {
59
+ if (typeof v === "string" && v.startsWith("$")) {
60
+ const name = v.slice(1);
61
+ if (bound[name] === undefined || bound[name] === null || bound[name] === "") { missing.push(name); continue; }
62
+ out[k] = String(bound[name]);
63
+ } else {
64
+ out[k] = v;
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * 실행 사양 + params_bound → 호출 인자. endpoints 에 키가 없으면 ok:false(endpoint_missing) —
72
+ * fail-closed 는 여기(브라우저)다: 카탈로그가 승인돼 있어도 이 CMS 가 그 API 를
73
+ * 모르면 카드는 열리지 않는다.
74
+ * @returns {{ok:boolean, error?:string, missing?:string[], method?:string, url?:string,
75
+ * path?:object, query?:object, data?:object}}
76
+ */
77
+ export function resolveExecution(execution, paramsBound, endpoints) {
78
+ const ex = execution && typeof execution === "object" ? execution : null;
79
+ if (!ex || !ex.endpoint_key) return { ok: false, error: "no_execution" };
80
+ const url = endpoints && typeof endpoints === "object" ? endpoints[ex.endpoint_key] : undefined;
81
+ if (typeof url !== "string" || !url) return { ok: false, error: "endpoint_missing", endpointKey: ex.endpoint_key };
82
+ const method = String(ex.method || "").toLowerCase();
83
+ if (!METHODS.includes(method)) return { ok: false, error: "invalid_method" };
84
+ const bound = paramsBound && typeof paramsBound === "object" ? paramsBound : {};
85
+ const missing = [];
86
+ const path = subst(ex.path, bound, missing);
87
+ const query = subst(ex.query, bound, missing);
88
+ const data = subst(ex.data, bound, missing);
89
+ if (missing.length) return { ok: false, error: "params_missing", missing };
90
+ return { ok: true, method, url, endpointKey: ex.endpoint_key, path, query, data };
91
+ }
92
+
93
+ const has = (list, code) => Array.isArray(list) && list.some((c) => String(c) === String(code));
94
+
95
+ /**
96
+ * 응답 판정 — 계약 §B.5. 규칙은 카탈로그(response_rule)에서 온다: 성공 판정이 음성
97
+ * 정의(code≠4000, HTTP 항상 200)인 CMS 가 실측됐기 때문에 기본값이 없다.
98
+ * HTTP 4xx → failed/http_client_error · 5xx → unknown/http_server_error(적용됐을 수 있음)
99
+ * 2xx: auth_expired_codes → failed/auth_expired · failure_codes 또는 success_field===false → failed/business
100
+ * · 그 외 succeeded
101
+ * 전송 실패·타임아웃 → unknown/transport (호출자가 err 로 넘긴다)
102
+ * @returns {{state:"succeeded"|"failed"|"unknown", reasonCode:string, message:string, authExpired:boolean}}
103
+ */
104
+ export function judgeResponse(rule, { httpStatus, body, error } = {}) {
105
+ const r = rule && typeof rule === "object" ? rule : {};
106
+ if (error) return { state: "unknown", reasonCode: "transport", message: String(error.message || error || "").slice(0, 200), authExpired: false };
107
+ const st = Number(httpStatus) || 0;
108
+ if (st >= 500) return { state: "unknown", reasonCode: "http_server_error", message: `HTTP ${st}`, authExpired: false };
109
+ if (st >= 400) return { state: "failed", reasonCode: "http_client_error", message: `HTTP ${st}`, authExpired: st === 401 || st === 403 };
110
+ if (st < 200 || st >= 300) return { state: "unknown", reasonCode: "http_other", message: `HTTP ${st}`, authExpired: false };
111
+ const b = body && typeof body === "object" ? body : {};
112
+ const codeField = String(r.code_field || "code");
113
+ const code = b[codeField];
114
+ const msg = String(b.message || b.msg || "").slice(0, 200);
115
+ if (code !== undefined && has(r.auth_expired_codes, code)) {
116
+ return { state: "failed", reasonCode: "auth_expired", message: msg, authExpired: true };
117
+ }
118
+ if (code !== undefined && has(r.failure_codes, code)) {
119
+ return { state: "failed", reasonCode: "business", message: msg, authExpired: false };
120
+ }
121
+ const sf = String(r.success_field || "");
122
+ if (sf && b[sf] === false) {
123
+ return { state: "failed", reasonCode: "business", message: msg, authExpired: false };
124
+ }
125
+ return { state: "succeeded", reasonCode: "", message: "", authExpired: false };
126
+ }
127
+
128
+ /** axios 응답·평문 응답 양쪽 수용 — {status, data}. */
129
+ function normalizeResponse(res) {
130
+ if (res && typeof res === "object" && typeof res.status === "number" && "data" in res) {
131
+ return { httpStatus: res.status, body: res.data };
132
+ }
133
+ return { httpStatus: 200, body: res };
134
+ }
135
+
136
+ /**
137
+ * CMS API 1회 호출 + 판정. **재시도 0.** adminApi.request({method,url,path,query,data}) 는
138
+ * CMS 의 기존 헬퍼(예: TempAdminApi.request) 참조 — 세션 인증·화이트리스트·진행바는
139
+ * 그 헬퍼의 몫이다. 반환은 허브 보고 형태.
140
+ * @returns {Promise<{state:string, reasonCode:string, message:string, httpStatus:number,
141
+ * latencyMs:number, authExpired:boolean, body?:any}>}
142
+ */
143
+ export async function executeViaAdminApi(adminApi, offer) {
144
+ const resolved = resolveExecution(offer && offer.execution, offer && offer.params_bound, adminApi && adminApi.endpoints);
145
+ if (!resolved.ok) {
146
+ return { state: "failed", reasonCode: resolved.error === "endpoint_missing" ? "endpoint_missing" : "invalid_execution",
147
+ message: resolved.error, httpStatus: 0, latencyMs: 0, authExpired: false };
148
+ }
149
+ const rule = (offer.execution && offer.execution.response_rule) || {};
150
+ const started = Date.now();
151
+ let out;
152
+ try {
153
+ const res = await adminApi.request({ method: resolved.method, url: resolved.url,
154
+ path: resolved.path, query: resolved.query, data: resolved.data });
155
+ const { httpStatus, body } = normalizeResponse(res);
156
+ out = { ...judgeResponse(rule, { httpStatus, body }), httpStatus, body };
157
+ } catch (err) {
158
+ // axios 류: err.response 가 있으면 전송·수신은 됐다 → 상태코드로 판정
159
+ const resp = err && err.response;
160
+ if (resp && typeof resp.status === "number") {
161
+ out = { ...judgeResponse(rule, { httpStatus: resp.status, body: resp.data }), httpStatus: resp.status, body: resp.data };
162
+ } else {
163
+ out = { ...judgeResponse(rule, { error: err || new Error("transport") }), httpStatus: 0 };
164
+ }
165
+ }
166
+ out.latencyMs = Date.now() - started;
167
+ return out;
168
+ }
package/agent.d.ts CHANGED
@@ -44,13 +44,37 @@ export type CxAgentSetup = Partial<CxAgentConfig> & {
44
44
  actionsImpl?: Storage;
45
45
  };
46
46
  /**
47
- * 처리 액션 (actions/1) 행위자 자격증명고객사가 발행한 **단명·목적 한정**
48
- * 토큰(aud=cx-action, 단위 ttl)을 돌려주는 함수. 없으면 액션 표면은 닫힌다
49
- * (선택지는 오되 실행 버튼이 없다). 전권 세션 토큰을 넘기는 것은 계약 위반.
47
+ * 처리 액션 (actions/1 프로파일 B브라우저 실행기): CMS 의 **기존** 관리자 API
48
+ * 헬퍼와 엔드포인트 맵을 참조로 넘긴다. 없으면 액션 표면은 닫힌다 (선택지는 오되
49
+ * 실행 버튼이 없다). 고객사가 새로 짜는 코드는 없다 이미 있는 것 두 개의 참조.
50
+ * request: ({method,url,path,query,data}) → axios 응답 {status,data} 또는 본문
51
+ * endpoints: {KEY: urlTemplate} (`:param` 표기)
52
+ * map?: {method: {urlTemplate: {}}} 화이트리스트 — 후보 메서드 추출용
53
+ * onAuthExpired?: 세션 만료 판정(response_rule.auth_expired_codes) 시 호출
50
54
  */
51
- actorAssertion?: () => string | Promise<string>;
55
+ adminApi?: AdminApi;
52
56
  };
53
57
 
58
+ export interface AdminApi {
59
+ request: (args: { method: string; url: string; path?: Record<string, string>;
60
+ query?: Record<string, string>; data?: Record<string, unknown> }) => Promise<unknown>;
61
+ endpoints: Record<string, string>;
62
+ map?: Record<string, Record<string, unknown>>;
63
+ onAuthExpired?: (body: unknown) => void;
64
+ }
65
+
66
+ /** 카탈로그 실행 사양 (offer 에 원문 그대로 실린다 — `$name` 은 params_bound 의 name) */
67
+ export interface ActionExecution {
68
+ endpoint_key: string;
69
+ method: "get" | "post" | "put" | "patch" | "delete";
70
+ path: Record<string, string>;
71
+ query: Record<string, string>;
72
+ data: Record<string, unknown>;
73
+ target_from: string[];
74
+ response_rule: { success_field?: string; code_field?: string; failure_codes?: Array<number | string>;
75
+ auth_expired_codes?: Array<number | string> };
76
+ }
77
+
54
78
  export interface ComposeHandle {
55
79
  promise: Promise<AnswerResult>;
56
80
  abort: () => void;
@@ -103,10 +127,17 @@ export declare class InquirySession {
103
127
  detachActionUi(): void;
104
128
  /** 이 문의에서 눌렀던 처리들 (정본은 허브 원장 — 브라우저 소실은 표시 손실뿐) */
105
129
  pendingActions(): PendingAction[];
106
- /** 실행 — 확인은 호출자가 끝낸 뒤. request_id 발급·전송 전 영속·재시도 0 */
130
+ /**
131
+ * 실행 (3단) — 확인은 호출자가 끝낸 뒤. ① 허브 선점(request_id 발급·전송 전 영속·재시도 0)
132
+ * ② adminApi.request 로 CMS 자기 API 1회 ③ 결과 보고. unknown 은 재실행하지 않는다.
133
+ */
107
134
  executeAction(offer: ActionOffer, opts?: { actorClaimed?: string }): Promise<ActionResult & { requestId: string }>;
108
- /** 재조정 lookup (재실행 없음). unknown 의미 있다 */
109
- reconcileAction(requestId: string): Promise<{ ok: boolean; state: string; reasonCode?: string; message?: string; error: string | null }>;
135
+ /** 미보고 결과 재보고 (재실행 아님) pending.unreported 것만 */
136
+ resendResult(requestId: string): Promise<{ ok: boolean; state?: string; error: string | null; httpStatus?: number }>;
137
+ /** 미보고 전부 재보고 — offer 갱신 시 자동 1회 */
138
+ flushUnreportedActions(): Promise<void>;
139
+ /** 잠금 조회 — target_key 있는 offer 마다 허브에 1회(세션 캐시). offers 의 locked 에 반영 */
140
+ refreshActionLocks(): Promise<void>;
110
141
  }
111
142
 
112
143
  /** 답변 payload 의 suggested_actions 항목 (허브 평탄화 뷰 — endpoint·트리거·승인 시각 없음) */
@@ -124,32 +155,43 @@ export interface ActionOffer {
124
155
  expires_at: string;
125
156
  catalog_version: number;
126
157
  offer_reason: "issue_key" | "context" | "tokens";
158
+ /** 프로파일 B: 실행 사양 (원문) 과 잠금 축 */
159
+ execution: ActionExecution | Record<string, never>;
160
+ target_key: string;
161
+ /** session.offers 가 병합하는 브라우저 판정 — 이 CMS 가 API 를 아는가 / 같은 대상·입력 최근 성공 */
162
+ available?: boolean;
163
+ locked?: boolean;
164
+ lock?: { at: string; actorClaimed: string; requestId: string } | null;
127
165
  }
128
166
 
129
167
  export interface PendingAction {
130
168
  requestId: string;
131
169
  offerId: string;
132
170
  actionKey: string;
171
+ targetKey?: string;
172
+ paramsHash?: string;
133
173
  label: string;
134
- state: "pending" | "succeeded" | "failed" | "unknown" | string;
174
+ state: "pending" | "received" | "succeeded" | "failed" | "unknown" | string;
135
175
  reasonCode?: string;
136
176
  message?: string;
177
+ /** 실행은 끝났으나 허브 보고가 안 됨 — resendResult 대상 */
178
+ unreported?: boolean;
137
179
  at: number;
138
180
  }
139
181
 
140
182
  export interface ActionResult {
141
183
  ok: boolean;
142
- /** succeeded | failed | unknown | in_flight | '' */
184
+ /** received | succeeded | failed | unknown | '' */
143
185
  state: string;
144
186
  reasonCode: string;
145
187
  message: string;
146
- actorAttested: string;
147
- auditRef: string;
148
- result: Record<string, unknown> | null;
149
- deduplicated: boolean;
150
- /** offer_locked | request_conflict | actor_assertion_required | actions_disabled | network_error | … */
188
+ targetKey?: string;
189
+ deduplicated?: boolean;
190
+ /** 보고까지 끝났는가 (false 면 unreported — resendResult) */
191
+ reported?: boolean;
192
+ /** offer_locked | request_conflict | endpoint_missing | admin_api_missing | network_error | … */
151
193
  error: string | null;
152
- httpStatus: number;
194
+ httpStatus?: number;
153
195
  }
154
196
 
155
197
  export interface CxAgent {
@@ -175,8 +217,12 @@ export interface CxAgent {
175
217
  investigationStatus(
176
218
  externalId: string | number,
177
219
  ): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
178
- /** 처리 액션 표면 열림 여부 — 전송 3요소 + actorAssertion */
220
+ /** 처리 액션 표면 열림 여부 — 전송 3요소 + adminApi(request·endpoints) */
179
221
  readonly actionsEnabled: boolean;
222
+ /** 후보 발행 완료 신호 (초기화 시 1회 자동) */
223
+ candidatesReady: Promise<{ ok: boolean; unchanged?: boolean; count?: number; snapshotHash?: string; error?: string | null }>;
224
+ /** 후보 재발행 — force 로 로컬 마커 무시 */
225
+ publishActionCandidates(opts?: { force?: boolean }): Promise<{ ok: boolean; unchanged?: boolean; count?: number; snapshotHash?: string; error?: string | null }>;
180
226
  inquirySent(payload: {
181
227
  externalId: string | number;
182
228
  inquiry: string;
@@ -195,3 +241,13 @@ export declare function textOf(messages: Record<string, string>, reason: string)
195
241
  // 헤드리스 액션 뷰 재수출 (view.d.ts 정본)
196
242
  export { ACTION_CLS, actionOffersTree, actionOffersView } from "./view.js";
197
243
  export type { ActionOfferItemView } from "./view.js";
244
+ // 브라우저 실행기 순수 함수 (actions.js) — 시험·커스텀 실행 경로용
245
+ export declare function buildCandidateSnapshot(adminApi: Pick<AdminApi, "endpoints" | "map">):
246
+ { snapshotHash: string; items: Array<{ endpoint_key: string; methods: string[]; path_template: string }> };
247
+ export declare function resolveExecution(execution: unknown, paramsBound: Record<string, string>, endpoints: Record<string, string>):
248
+ { ok: boolean; error?: string; missing?: string[]; method?: string; url?: string;
249
+ path?: Record<string, string>; query?: Record<string, string>; data?: Record<string, unknown> };
250
+ export declare function judgeResponse(rule: unknown, input: { httpStatus?: number; body?: unknown; error?: unknown }):
251
+ { state: "succeeded" | "failed" | "unknown"; reasonCode: string; message: string; authExpired: boolean };
252
+ export declare function executeViaAdminApi(adminApi: AdminApi, offer: ActionOffer):
253
+ Promise<{ state: "succeeded" | "failed" | "unknown"; reasonCode: string; message: string; httpStatus: number; latencyMs: number; authExpired: boolean; body?: unknown }>;
package/agent.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * CxAgent — 0.3.0 객체 표면. `createCxAgent(config)` 하나로 테넌트를 세운다.
2
+ * CxAgent — 0.3.0 객체 표면 (0.5.0: 처리 액션 브라우저 실행기). `createCxAgent(config)`
3
+ * 하나로 테넌트를 세운다.
3
4
  *
4
5
  * 구조 원칙 (객체화의 이유):
5
6
  * - **보편 구조는 코어가 소유한다** — 어느 CMS 든 존재하는 것들: 설정
@@ -27,10 +28,12 @@ import { CxAgentClient, newRequestId } from "./client.js";
27
28
  import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
28
29
  import { ActionLedger, InquirySession, SessionStore } from "./session.js";
29
30
  import { ACTION_CLS, actionOffersTree, actionOffersView } from "./view.js";
31
+ import { buildCandidateSnapshot, executeViaAdminApi, judgeResponse, resolveExecution } from "./actions.js";
30
32
 
31
33
  // 헤드리스 액션 뷰 — 프레임워크 어댑터(react/vue) 슬롯은 0.4.x 후속. 그전엔
32
34
  // 소비처가 이 순수 함수로 자기 렌더러에 그린다 (텍스트 노드만 — innerHTML 금지).
33
35
  export { ACTION_CLS, actionOffersTree, actionOffersView };
36
+ export { buildCandidateSnapshot, executeViaAdminApi, judgeResponse, resolveExecution };
34
37
 
35
38
  export { LOCALES, MESSAGES, normalizeLocale };
36
39
 
@@ -91,9 +94,11 @@ export function createCxAgent(config = {}) {
91
94
  source = "cms",
92
95
  draftDecorators = [koreanGreetingDecorator],
93
96
  storage = {},
94
- // 처리 액션 (actions/1): 고객사가 발행한 단명·목적 한정 행위자 토큰을 돌려주는
95
- // 함수. 없으면 액션 표면은 닫힌다 (offers 오되 실행 버튼은 없다).
96
- actorAssertion,
97
+ // 처리 액션 (actions/1 프로파일 B — 브라우저 실행기): CMS 기존 관리자 API
98
+ // 헬퍼와 엔드포인트 맵을 참조로 넘긴다 { request, endpoints, map?, onAuthExpired? }.
99
+ // 없으면 액션 표면은 닫힌다 (offers 는 오되 실행 버튼은 없다). 고객사가 새로
100
+ // 짜는 코드는 없다: 이미 있는 것 두 개의 참조뿐 (0.4.x actorAssertion 폐기).
101
+ adminApi,
97
102
  ...rest
98
103
  } = config;
99
104
 
@@ -185,14 +190,42 @@ export function createCxAgent(config = {}) {
185
190
  const actionLedger = storage.enabled === false || !client
186
191
  ? null
187
192
  : new ActionLedger({ impl: storage.actionsImpl, prefix: `cx-agent-actions:${domain}` });
193
+ const admin = adminApi && typeof adminApi === "object" && typeof adminApi.request === "function"
194
+ && adminApi.endpoints && typeof adminApi.endpoints === "object" ? adminApi : null;
188
195
  const sessionDeps = {
189
196
  client, messages, decorate, store,
190
197
  textOf: (reason) => textOf(messages, reason),
191
- actorAssertion: typeof actorAssertion === "function" ? actorAssertion : null,
192
- actionLedger, newRequestId,
198
+ adminApi: admin, actionLedger, newRequestId,
193
199
  };
194
200
  const sessions = new Map();
195
201
 
202
+ // 후보 발행 — "이 CMS 가 이미 하는 처리" 스냅샷(키·메서드·경로 템플릿, 호스트 없음).
203
+ // 지문이 마지막 발행과 같으면 네트워크 0 (localStorage 마커, 실패 시 다음 로드에
204
+ // 재시도). 허브도 같은 해시면 쓰기 0. 백그라운드 — 화면을 막지 않는다.
205
+ const publishCandidates = async ({ force = false } = {}) => {
206
+ if (!client || !admin || api !== "hub") return { ok: false, error: "not_configured" };
207
+ const snap = buildCandidateSnapshot(admin);
208
+ const key = `cx-agent-catalog:${domain}`;
209
+ let ls = null;
210
+ try { ls = storage.actionsImpl || (typeof localStorage !== "undefined" ? localStorage : null); } catch { ls = null; }
211
+ if (!force && ls) {
212
+ try {
213
+ const prev = JSON.parse(ls.getItem(key) || "null");
214
+ if (prev && prev.hash === snap.snapshotHash && Date.now() - (prev.at || 0) < 24 * 60 * 60 * 1000) {
215
+ return { ok: true, unchanged: true, count: snap.items.length, snapshotHash: snap.snapshotHash };
216
+ }
217
+ } catch { /* 마커 손상 — 발행 진행 */ }
218
+ }
219
+ const r = await client.publishActionCandidates(snap);
220
+ if (r.ok && ls) {
221
+ try { ls.setItem(key, JSON.stringify({ hash: snap.snapshotHash, at: Date.now() })); } catch { /* quota */ }
222
+ }
223
+ return { ...r, snapshotHash: snap.snapshotHash, count: snap.items.length };
224
+ };
225
+ const candidatesReady = admin && client && api === "hub"
226
+ ? publishCandidates().catch(() => ({ ok: false, error: "network_error" }))
227
+ : Promise.resolve({ ok: false, error: "not_configured" });
228
+
196
229
  return {
197
230
  /** 전송 3요소가 다 있으면 true */
198
231
  enabled: Boolean(client),
@@ -246,8 +279,12 @@ export function createCxAgent(config = {}) {
246
279
  return client.investigationStatus(externalId);
247
280
  },
248
281
 
249
- /** 처리 액션 표면이 열려 있는가 — 전송 3요소 + actorAssertion 함수 */
250
- get actionsEnabled() { return Boolean(client && sessionDeps.actorAssertion); },
282
+ /** 처리 액션 표면이 열려 있는가 — 전송 3요소 + adminApi(request·endpoints) */
283
+ get actionsEnabled() { return Boolean(client && sessionDeps.adminApi); },
284
+ /** 후보 발행 완료 신호 (초기화 시 1회 자동) — 결과 {ok, unchanged, count, snapshotHash} */
285
+ candidatesReady,
286
+ /** 후보 재발행 — force 로 마커 무시 (CMS 배포 직후 등) */
287
+ publishActionCandidates(opts) { return publishCandidates(opts || {}); },
251
288
 
252
289
  /** 문의+최종답변 쌍 적재 (fire-and-forget, external_id 멱등) */
253
290
  inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
package/angular.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Angular 진입점 — 표준 커스텀 엘리먼트를 쓴다 (CUSTOM_ELEMENTS_SCHEMA). */
2
- export { defineCxAiSuggest, TAG } from "./element.js";
2
+ export { defineCxAiSuggest, TAG, defineCxActionOffers, ACTIONS_TAG } from "./element.js";
3
3
  export { aiSuggestView } from "./view.js";
4
4
  export type { AiSuggestView } from "./view.js";
5
- export type { CxAiSuggestElement } from "./element.js";
5
+ export type { CxAiSuggestElement, CxActionOffersElement } from "./element.js";
package/angular.js CHANGED
@@ -34,5 +34,5 @@
34
34
  * `angular.json` 의 styles 배열)에 넣는다. 컴포넌트 스코프 스타일에 넣으면
35
35
  * 뷰 캡슐화 때문에 안 닿는다 — 이 엘리먼트는 섀도 DOM 을 쓰지 않는다.
36
36
  */
37
- export { defineCxAiSuggest, TAG } from "./element.js";
37
+ export { defineCxAiSuggest, TAG, defineCxActionOffers, ACTIONS_TAG } from "./element.js";
38
38
  export { aiSuggestView } from "./view.js";
package/astro.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { CxHook } from "./index.js";
2
2
 
3
- export { defineCxAiSuggest, TAG } from "./element.js";
3
+ export { defineCxAiSuggest, TAG, defineCxActionOffers, ACTIONS_TAG } from "./element.js";
4
4
  export { aiSuggestView } from "./view.js";
5
5
  export type { AiSuggestView } from "./view.js";
6
- export type { CxAiSuggestElement } from "./element.js";
6
+ export type { CxAiSuggestElement, CxActionOffersElement } from "./element.js";
7
7
 
8
8
  /** 엘리먼트 등록 + 값 주입 + 채택 구독을 한 번에. 반환값은 구독 해제. */
9
9
  export declare function setupCxAiSuggest(
package/astro.js CHANGED
@@ -34,6 +34,7 @@
34
34
  import { defineCxAiSuggest, TAG } from "./element.js";
35
35
 
36
36
  export { defineCxAiSuggest, TAG };
37
+ export { defineCxActionOffers, ACTIONS_TAG } from "./element.js";
37
38
  export { aiSuggestView } from "./view.js";
38
39
 
39
40
  /** 엘리먼트 등록 + 값 주입 + 채택 구독을 한 번에.