@underdogai/mesh-app-sdk 0.6.0 → 0.9.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/src/runtime.ts CHANGED
@@ -12,12 +12,15 @@ import {
12
12
  EventType,
13
13
  NS,
14
14
  buildUiActionContent,
15
+ buildUiActionResult,
15
16
  buildUiMessageContent,
16
17
  buildUiMessageEdit,
17
18
  canPublish,
18
19
  classifySender,
19
20
  validateEvent,
20
21
  type UiActionClient,
22
+ type UiActionContent,
23
+ type UiActionResultContent,
21
24
  type UiCardContent,
22
25
  type WorkflowStateContent,
23
26
  } from "@underdogai/mesh-event-schemas";
@@ -50,7 +53,17 @@ export type Effect =
50
53
  // 발행 계열(외부 SDK Phase 1) — emit(자기 사실 발행)/invoke(타 앱 액션 호출 = UI 없는 org.corp.ui.action).
51
54
  // 형태는 데이터로 남고, 실제 전송은 호스트가 applyEffects 로 적용한다(테스트/증명은 배열만 검사).
52
55
  | { kind: "emit"; type: string; content: Record<string, unknown>; form: EmitForm; stateKey?: string }
53
- | { kind: "invoke"; actionId: string; values: Record<string, unknown>; label?: string };
56
+ | { kind: "invoke"; actionId: string; values: Record<string, unknown>; label?: string }
57
+ /**
58
+ * 액션 한 건의 판정 결과를 **누른 사람에게** 되돌린다(0.7.0 — `org.corp.ui.action.result`).
59
+ *
60
+ * 선언은 "무엇을 말하는가"만 담는다: 수신자(`to`)·카드 이벤트·액션 참조는 **호스트가 채운다**
61
+ * (핸들러는 자기가 어떤 이벤트에 응답 중인지 알 필요가 없다 — `edit` 가 `cardEventId` 를 모르는 것과 같다).
62
+ */
63
+ | { kind: "result"; result: ActionResult };
64
+
65
+ /** `ctx.result(...)` 가 받는 것 — 와이어에서 호스트가 채우는 네 키를 뺀 나머지. */
66
+ export type ActionResult = Omit<UiActionResultContent, "v" | "to" | "card_event_id" | "m.relates_to">;
54
67
 
55
68
  /**
56
69
  * 호스트가 주입하는 백엔드 핸들 — 핸들러가 "권위 데이터를 읽고 → 판단 → 효과 선언"(read-then-decide)할 때 쓴다.
@@ -87,6 +100,15 @@ export interface ActionCtx {
87
100
  cardId?: string;
88
101
  /** 폼 입력(원본). 핸들러는 action.values.parse(ctx.values) 로 타입 확보. */
89
102
  values: Record<string, unknown>;
103
+ /**
104
+ * 이 호출이 **무엇이었나** (0.9.0). 대개 안 봐도 된다 — 단 하나, `"query"` 는 다르다:
105
+ * 사람이 누른 게 아니라 `select` 의 `source: { kind: "query" }` 가 **타이핑 중에** 보낸 요청이라,
106
+ * 핸들러는 부작용 없이 `ctx.result({ kind: "options", … })` 로만 답해야 한다.
107
+ *
108
+ * 🔴 **자기 신고다** — 권위는 여전히 `sender` 와 액션 정의(`scope`·`destructive`)다. 이 값으로
109
+ * 권한을 가르지 말고, "무엇을 만들지"만 가른다. 옛 클라는 안 싣거나 `"button"` 을 싣는다.
110
+ */
111
+ action_type?: UiActionContent["action_type"];
90
112
  /** 작성 클라이언트 환경(있으면, G6) — 클라의 자기 신고라 표시·진단용이지 권위 아님(권위는 sender). */
91
113
  client?: UiActionClient;
92
114
  /** 편집 대상 원본 카드 event_id(있으면). */
@@ -123,8 +145,27 @@ export interface ActionCtx {
123
145
  * 받는 쪽은 버튼에서 왔는지 봇이 보냈는지 구분하지 않는다. action_id 접두사(<app>.)가 라우팅 주소다.
124
146
  */
125
147
  invoke(actionId: string, values?: Record<string, unknown>, opts?: { label?: string }): void;
148
+ /**
149
+ * 효과 선언 헬퍼 — 이 액션의 **판정 결과를 누른 사람에게** 되돌린다(0.7.0).
150
+ *
151
+ * ctx.result({ kind: "reject", errors: { due: "오늘 이후여야 해요" } })
152
+ * ctx.result({ kind: "denied", reason: "현재 수신자만 처리할 수 있어요" })
153
+ * ctx.result({ kind: "ok", message: "확인 처리됨" })
154
+ * ctx.result({ kind: "options", name: "to", options: [...] }) // 동적 후보(select source:query)
155
+ *
156
+ * 🔴 **`send` 로 오류 카드를 쌓던 자리를 대신한다** — 결과가 방이 아니라 누른 사람의 그 컨트롤로 간다.
157
+ * ⚠️ 한 핸들러가 `result` 를 여러 번 내면 **마지막 것만** 남는다(같은 액션에 대한 판정이 여럿일 수
158
+ * 없다 — 클라 인덱스도 같은 규칙으로 최신 하나만 그린다). 효과 배열에는 그대로 쌓이지만
159
+ * `applyEffects` 가 마지막 하나만 보낸다.
160
+ * ⚠️ `card_id`·`action_id` 는 안 줘도 된다 — 지금 처리 중인 액션의 것을 SDK 가 채운다.
161
+ */
162
+ result(r: PartialActionResult): void;
126
163
  }
127
164
 
165
+ /** `ctx.result(...)` 의 인자 — `card_id`/`action_id` 는 SDK 가 채우므로 생략할 수 있다. */
166
+ export type PartialActionResult = Omit<ActionResult, "card_id" | "action_id"> &
167
+ Partial<Pick<ActionResult, "card_id" | "action_id">>;
168
+
128
169
  export type ActionHandler = (ctx: ActionCtx) => void | Promise<void>;
129
170
 
130
171
  /** 호스트가 주입하는 권한 게이트(= appservice-agent/policy.evaluateAction 의 얇은 래퍼). */
@@ -157,6 +198,17 @@ export interface DispatchInput {
157
198
  stateReader?: (type: string, stateKey: string) => Promise<Record<string, unknown> | null>;
158
199
  /** 호스트 주입 앱 정책 설정(선택) — ctx.config 로 핸들러에 전달(예: { approvalThreshold }). */
159
200
  config?: Record<string, unknown>;
201
+ /**
202
+ * 값 검증 실패·권한 거절에 **결과 효과를 자동으로 붙일지**(기본 true, 0.7.0).
203
+ *
204
+ * 왜 기본이 켬인가: 이 두 경로는 지금까지 **핸들러에 도달조차 안 했다** — 앱 코드가 개입할 자리가
205
+ * 없어서 사용자는 아무 반응도 못 봤다(`ignored`) 또는 앱이 따로 오류 카드를 쌓았다. 자동으로 붙이면
206
+ * **앱을 한 줄도 안 고치고** 옛 앱의 거절·검증 실패가 누른 사람의 그 컨트롤로 인라인 회신된다.
207
+ *
208
+ * 끄는 경우: 결과 문구를 앱이 전부 소유하고 싶거나(전환기 중복 방지), 증명 스크립트가 효과 배열을
209
+ * 엄격히 재는 경우.
210
+ */
211
+ autoResult?: boolean;
160
212
  }
161
213
 
162
214
  export interface DispatchResult {
@@ -173,9 +225,20 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
173
225
  return { status: "ignored", reason: "publish_forbidden", effects: [] };
174
226
  }
175
227
  // 2) 레지스트리 검증 — 스키마 불일치/알 수 없는 타입이면 무시.
228
+ // 🔴 status 는 그대로 "ignored" 다(핸들러는 안 돈다). 달라진 건 **말해 준다**는 것뿐이다:
229
+ // 스키마 불일치는 거의 항상 사용자가 채운 값의 문제라, 누른 사람의 그 필드 아래에 문장이 가야 한다.
230
+ // 안 그러면 지금처럼 버튼을 눌러도 화면에 아무 일도 일어나지 않는다.
176
231
  const v = validateEvent(EventType.UiAction, input.actionContent);
177
- if (!v.ok) return { status: "ignored", reason: v.reason, effects: [] };
178
- const action = v.data as { card_id: string; action_id: string; values?: Record<string, unknown>; client?: UiActionClient };
232
+ if (!v.ok) {
233
+ return { status: "ignored", reason: v.reason, effects: rejectEffects(input, v.issues) };
234
+ }
235
+ const action = v.data as {
236
+ card_id: string;
237
+ action_id: string;
238
+ values?: Record<string, unknown>;
239
+ client?: UiActionClient;
240
+ action_type?: UiActionContent["action_type"];
241
+ };
179
242
 
180
243
  // 3) 앱이 아는 액션인가.
181
244
  const def = input.app.actions.get(action.action_id);
@@ -191,7 +254,23 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
191
254
  capability: input.capability,
192
255
  });
193
256
  if (!decision.allowed) {
194
- return { status: "denied", actionId: action.action_id, reason: decision.reason, effects: [] };
257
+ // 🔴 거절도 **말해 준다**. 지금까지는 status denied 돌아가고 화면엔 아무 일도 없거나,
258
+ // 앱이 "수신자만 처리할 수 있어요" 카드를 방 전원에게 쌓았다. 이유는 이미 손에 있다(게이트가 준다).
259
+ const effects: Effect[] =
260
+ input.autoResult === false
261
+ ? []
262
+ : [
263
+ {
264
+ kind: "result",
265
+ result: {
266
+ card_id: action.card_id,
267
+ action_id: action.action_id,
268
+ kind: "denied",
269
+ reason: decision.reason,
270
+ },
271
+ },
272
+ ];
273
+ return { status: "denied", actionId: action.action_id, reason: decision.reason, effects };
195
274
  }
196
275
 
197
276
  // 5) 핸들러 실행 — 효과 수집(부작용은 호스트가 적용).
@@ -204,6 +283,7 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
204
283
  entity: input.entity ?? {},
205
284
  cardId: action.card_id,
206
285
  values: action.values ?? {},
286
+ ...(action.action_type ? { action_type: action.action_type } : {}),
207
287
  client: action.client,
208
288
  cardEventId: input.cardEventId,
209
289
  // backend 는 이 액션이 선언한 tools 화이트리스트로 감싼다(confused-deputy 방어, fail-closed).
@@ -217,12 +297,60 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
217
297
  delegate: (d) => effects.push({ kind: "delegate", intent: d.intent, payload: d.payload }),
218
298
  emit: publish.emit,
219
299
  invoke: publish.invoke,
300
+ result: (r) =>
301
+ effects.push({
302
+ kind: "result",
303
+ result: { card_id: action.card_id, action_id: action.action_id, ...r },
304
+ }),
220
305
  };
221
306
  await handler(ctx);
222
307
  }
223
308
  return { status: "ran", actionId: action.action_id, effects };
224
309
  }
225
310
 
311
+ /**
312
+ * 스키마 불일치를 **필드별 문장**으로 바꾼다(0.7.0). zod issue 의 경로가 필드 이름을 들고 있다 —
313
+ * 폼 값은 `values.<name>` 아래 살므로 그 한 겹을 벗긴다(`values.due: …` → `due`).
314
+ *
315
+ * 🔴 **best-effort 다.** content 자체가 스키마를 통과하지 못한 상태라 `card_id`/`action_id` 조차
316
+ * 믿을 수 없다 — 둘 다 문자열로 읽히지 않으면 회신을 **포기한다**(잘못된 카드에 오류를 붙이는 것보다
317
+ * 아무 말도 안 하는 쪽이 낫다). 경로가 `values` 아래가 아니면(예: `action_type`) 필드로 못 붙이므로
318
+ * `kind: "reject"` 대신 한 줄짜리 `denied` 로 떨어뜨린다 — 사람이 고칠 수 있는 게 없는 오류라서다.
319
+ */
320
+ function rejectEffects(input: DispatchInput, issues: string[] | undefined): Effect[] {
321
+ if (input.autoResult === false) return [];
322
+ const c = input.actionContent as Record<string, unknown> | null | undefined;
323
+ const cardId = typeof c?.card_id === "string" ? c.card_id : undefined;
324
+ const actionId = typeof c?.action_id === "string" ? c.action_id : undefined;
325
+ if (!cardId || !actionId) return [];
326
+
327
+ const errors: Record<string, string> = {};
328
+ const rest: string[] = [];
329
+ for (const issue of issues ?? []) {
330
+ const at = issue.indexOf(": ");
331
+ const path = at >= 0 ? issue.slice(0, at) : "";
332
+ const message = at >= 0 ? issue.slice(at + 2) : issue;
333
+ const segments = path.split(".").filter(Boolean);
334
+ // `values.<name>` → 그 필드. `values` 안쪽으로 더 깊어도(배열 인덱스 등) 첫 이름이 필드다.
335
+ if (segments[0] === "values" && segments[1]) errors[segments[1]] ??= message;
336
+ else rest.push(issue);
337
+ }
338
+ if (Object.keys(errors).length) {
339
+ return [{ kind: "result", result: { card_id: cardId, action_id: actionId, kind: "reject", errors } }];
340
+ }
341
+ return [
342
+ {
343
+ kind: "result",
344
+ result: {
345
+ card_id: cardId,
346
+ action_id: actionId,
347
+ kind: "denied",
348
+ reason: rest[0] ?? "schema_mismatch",
349
+ },
350
+ },
351
+ ];
352
+ }
353
+
226
354
  // ---------------------------------------------------------------------------
227
355
  // 발행 헬퍼(emit/invoke) — ActionCtx/EventCtx 가 공유하는 효과 수집기
228
356
  // ---------------------------------------------------------------------------
@@ -444,6 +572,14 @@ export interface ApplyEffectsInput {
444
572
  entity?: { type?: string; id?: string };
445
573
  /** workflow.updated_by 로 기록할 주체(보통 액션 발신자). */
446
574
  sender?: string;
575
+ /**
576
+ * `result` 효과의 **수신자**(0.7.0) — 이 액션을 누른 사람의 MXID. 없으면 결과를 보낼 곳이 없으므로
577
+ * 경고 후 건너뛴다(throw 하지 않는다 — 결과 회신은 부가 기능이고, 그것 때문에 뒤따르는 카드 편집이
578
+ * 취소되면 더 나쁘다. `workflow` 가 로그 후 계속하는 것과 같은 판단).
579
+ */
580
+ actor?: string;
581
+ /** `result` 효과가 가리킬 **액션** 이벤트 id(0.7.0). 없으면 `m.relates_to` 없이 보낸다(card_id 로 붙는다). */
582
+ actionEventId?: string;
447
583
  /**
448
584
  * delegate 효과 콜백 — stateful·교차에이전트(A2A) 프로토콜은 호스트가 소유한다. 미주입 호스트(1회 발행·
449
585
  * 단순 봇)에서 delegate 가 나오면 경고 후 무시한다(조용한 유실 방지 로그).
@@ -482,7 +618,10 @@ export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
482
618
  const g = globalThis as { console?: { warn(m: string): void; error(m: string): void } };
483
619
  const warn = input.warn ?? ((m: string) => g.console?.warn(m));
484
620
  const error = input.error ?? ((m: string) => g.console?.error(m));
485
- for (const e of input.effects) {
621
+ // 같은 액션의 판정은 하나뿐이다 — 핸들러가 result 를 여러 번 내면 **마지막 것만** 보낸다
622
+ // (클라 인덱스도 같은 규칙으로 최신 하나만 그린다. 여러 장을 보내면 화면에서 서로를 덮어 깜빡인다).
623
+ const lastResult = input.effects.reduce<number>((at, e, i) => (e.kind === "result" ? i : at), -1);
624
+ for (const [i, e] of input.effects.entries()) {
486
625
  switch (e.kind) {
487
626
  case "edit": {
488
627
  // 편집 대상 카드가 있으면 m.replace, 없으면(참조 유실) 새 카드로 graceful.
@@ -554,6 +693,31 @@ export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
554
693
  await input.client.sendEvent(input.roomId, EventType.UiAction, content as unknown as Record<string, unknown>);
555
694
  break;
556
695
  }
696
+ case "result": {
697
+ if (i !== lastResult) break; // 마지막 판정만 나간다(위 주석).
698
+ if (!input.actor) {
699
+ // 보낼 곳을 모르면 그냥 건너뛴다 — throw 하면 이미 화면에 반영된 카드 편집까지 취소된다.
700
+ warn(
701
+ `[app-sdk] result 효과 생략 — 수신자(actor) 미해석 (action_id=${e.result.action_id}, kind=${e.result.kind}).` +
702
+ " 호스트가 applyEffects 에 actor(액션 발신자)를 넘겨야 합니다.",
703
+ );
704
+ break;
705
+ }
706
+ const content: UiActionResultContent = buildUiActionResult({
707
+ ...e.result,
708
+ to: input.actor,
709
+ ...(input.cardEventId ? { card_event_id: input.cardEventId } : {}),
710
+ ...(input.actionEventId
711
+ ? { "m.relates_to": { rel_type: "m.reference" as const, event_id: input.actionEventId } }
712
+ : {}),
713
+ });
714
+ await input.client.sendEvent(
715
+ input.roomId,
716
+ EventType.UiActionResult,
717
+ content as unknown as Record<string, unknown>,
718
+ );
719
+ break;
720
+ }
557
721
  }
558
722
  }
559
723
  }
package/src/serve.ts CHANGED
@@ -523,15 +523,26 @@ export async function serve(app: App, opts: ServeOptions): Promise<void> {
523
523
  .catch(() => null),
524
524
  config: opts.config,
525
525
  });
526
+ // `actor`/`actionEventId` — `result` 효과(0.7.0)가 "누구에게, 어느 액션의 답으로" 가는지.
527
+ // 안 넘기면 결과 회신이 조용히 생략된다(applyEffects 가 경고만 하고 건너뛴다).
528
+ const effectInput = {
529
+ client, app, roomId, cardEventId, entity,
530
+ sender: ev.sender, actor: ev.sender, actionEventId: ev.event_id,
531
+ onDelegate: opts.onDelegate,
532
+ };
526
533
  // 자기 것이 아니면(unknown_action 등 ignored) 아무것도 발행하지 않는다 — 외부 앱 공존의 반쪽(§1.5의 역).
527
534
  if (res.status === "ran" && res.effects.length) {
528
- await applyEffects({
529
- client, app, roomId, cardEventId, entity,
530
- sender: ev.sender, effects: res.effects, onDelegate: opts.onDelegate,
531
- });
535
+ await applyEffects({ ...effectInput, effects: res.effects });
532
536
  log(`[app-sdk] 액션 실행 (${res.actionId}, effects=${res.effects.length})`);
533
537
  } else if (res.status === "denied") {
534
- log(`[app-sdk] 액션 거부 (${res.actionId}): ${res.reason}`); // 거부 카드 발행 여부는 정책 — 기본은 로그만.
538
+ // 🔴 거부도 **누른 사람에게는 말해 준다**(0.7.0) dispatchAction 붙인 `result` 효과만 적용한다.
539
+ // 방에 거부 *카드*를 쌓을지는 여전히 앱 정책이다(기본은 안 쌓는다). 그 경계가 이 채널의 요점이다.
540
+ await applyEffects({ ...effectInput, effects: res.effects.filter((e) => e.kind === "result") });
541
+ log(`[app-sdk] 액션 거부 (${res.actionId}): ${res.reason}`);
542
+ } else if (res.effects.some((e) => e.kind === "result")) {
543
+ // 값 검증 실패(schema_mismatch) — 핸들러는 안 돌았지만 사용자는 왜 아무 일도 안 일어났는지 알아야 한다.
544
+ await applyEffects({ ...effectInput, effects: res.effects.filter((e) => e.kind === "result") });
545
+ log(`[app-sdk] 액션 무시 (${res.reason}) — 결과 회신만 보냈다`);
535
546
  }
536
547
  return;
537
548
  }