@underdogai/mesh-app-sdk 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/action.d.ts +55 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +33 -0
- package/dist/action.js.map +1 -0
- package/dist/app.d.ts +126 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +214 -0
- package/dist/app.js.map +1 -0
- package/dist/builders.d.ts +77 -0
- package/dist/builders.d.ts.map +1 -0
- package/dist/builders.js +72 -0
- package/dist/builders.js.map +1 -0
- package/dist/host.d.ts +38 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +58 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +232 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +246 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serve.d.ts +119 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +415 -0
- package/dist/serve.js.map +1 -0
- package/package.json +53 -0
- package/src/action.ts +74 -0
- package/src/app.ts +350 -0
- package/src/builders.ts +87 -0
- package/src/host.ts +87 -0
- package/src/index.ts +15 -0
- package/src/runtime.ts +462 -0
- package/src/serve.ts +520 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 액션 라우터 — 들어온 org.corp.ui.action 을 (멱등은 호스트가) → 발행자격(SEC-3) → 레지스트리 검증
|
|
3
|
+
* → 권한 게이트 → 핸들러 실행(효과 수집) 으로 흘린다.
|
|
4
|
+
*
|
|
5
|
+
* 보안 게이트(evaluateAction)는 SDK 가 소유하지 않고 **호스트가 주입**한다(의존성 역전) — SDK 는
|
|
6
|
+
* "어떤 액션이 어떤 scope/destructive 인지"만 게이트에 데이터로 넘긴다(보안의 권위는 서버 policy 에 그대로).
|
|
7
|
+
*
|
|
8
|
+
* 핸들러는 부작용을 직접 실행하지 않고 "효과(Effect)"를 선언한다 → 실 AS 는 matrix client 로 적용,
|
|
9
|
+
* 증명 스크립트는 효과 배열을 검사한다(같은 SDK 코드를 양쪽에서 검증 = 테스트성).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
EventType,
|
|
13
|
+
NS,
|
|
14
|
+
buildUiActionContent,
|
|
15
|
+
buildUiMessageContent,
|
|
16
|
+
buildUiMessageEdit,
|
|
17
|
+
canPublish,
|
|
18
|
+
classifySender,
|
|
19
|
+
validateEvent,
|
|
20
|
+
type UiCardContent,
|
|
21
|
+
type WorkflowStateContent,
|
|
22
|
+
} from "@underdogai/mesh-event-schemas";
|
|
23
|
+
import type { App } from "./app.js";
|
|
24
|
+
import type { AppAction } from "./action.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 발행 형태 — 같은 사실을 어떤 Matrix 형태로 보낼지(킥오프 §2.3).
|
|
28
|
+
* - "message": m.room.message + 카드(사람용 — 타임라인에 남고 알림도 간다). events 에 선언된 view 가 그린다.
|
|
29
|
+
* - "event"(기본): 커스텀 이벤트 타입 그대로(기계가 읽을 사실·봇 사이 연쇄 — 남지만 조용하다).
|
|
30
|
+
* - "state": 상태 이벤트(최신값 하나 — 방 구성·현재 상태).
|
|
31
|
+
*/
|
|
32
|
+
export type EmitForm = "message" | "event" | "state";
|
|
33
|
+
|
|
34
|
+
export interface EmitOptions {
|
|
35
|
+
/** 발행 형태(기본 "event"). */
|
|
36
|
+
form?: EmitForm;
|
|
37
|
+
/** form:"state" 의 state_key(기본 ""). 다른 form 에선 무시된다. */
|
|
38
|
+
stateKey?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 핸들러가 선언하는 효과 — 호스트가 matrix 로 적용한다. */
|
|
42
|
+
export type Effect =
|
|
43
|
+
| { kind: "edit"; card: UiCardContent } // 원본 카드 m.replace 편집
|
|
44
|
+
| { kind: "send"; card: UiCardContent } // 새 카드 전송
|
|
45
|
+
| { kind: "workflow"; status: string } // org.corp.workflow.state 갱신
|
|
46
|
+
// 다른 에이전트에게 위임(예: 2차 승인 A2A). 동기 effect 모델로 표현 못 하는 stateful·교차에이전트
|
|
47
|
+
// 프로토콜(pending/timeout/result 매칭)은 호스트가 소유하고, 핸들러는 "위임한다"는 의도+페이로드만 선언한다.
|
|
48
|
+
| { kind: "delegate"; intent: string; payload: Record<string, unknown> }
|
|
49
|
+
// 발행 계열(외부 SDK Phase 1) — emit(자기 사실 발행)/invoke(타 앱 액션 호출 = UI 없는 org.corp.ui.action).
|
|
50
|
+
// 형태는 데이터로 남고, 실제 전송은 호스트가 applyEffects 로 적용한다(테스트/증명은 배열만 검사).
|
|
51
|
+
| { kind: "emit"; type: string; content: Record<string, unknown>; form: EmitForm; stateKey?: string }
|
|
52
|
+
| { kind: "invoke"; actionId: string; values: Record<string, unknown> };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 호스트가 주입하는 백엔드 핸들 — 핸들러가 "권위 데이터를 읽고 → 판단 → 효과 선언"(read-then-decide)할 때 쓴다.
|
|
56
|
+
* SDK 는 호출 형태(도구 이름 + 구조화 인자)만 정의하고, 구현(MCP 도구/HTTP)·권한 스코프는 호스트가 소유한다
|
|
57
|
+
* (GateFn 과 같은 의존성 역전). 앱은 ctx.backend 가 있으면 권위 재조회로 쓰고, 없으면(증명/오프라인) 분기로 처리한다.
|
|
58
|
+
* ⚠️ 카드/이벤트의 ctx.values 는 신뢰하지 말고, 여기로 권위 데이터를 다시 읽는 것이 시스템-오브-레코드 원칙.
|
|
59
|
+
*/
|
|
60
|
+
export interface ActionBackend {
|
|
61
|
+
call<T = unknown>(tool: string, args?: Record<string, unknown>): Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ActionCtx {
|
|
65
|
+
/** 홈서버가 귀속한 발신자 MXID(권위). */
|
|
66
|
+
sender: string;
|
|
67
|
+
/** 카드 context.entity (있으면) — 핸들러가 권위 데이터 재조회 키로 사용. */
|
|
68
|
+
entity: { type?: string; id?: string };
|
|
69
|
+
/** 원본 카드의 논리 card_id(action.card_id) — 편집 카드가 같은 card_id 를 유지해 같은 카드를 m.replace 하도록. */
|
|
70
|
+
cardId?: string;
|
|
71
|
+
/** 폼 입력(원본). 핸들러는 action.values.parse(ctx.values) 로 타입 확보. */
|
|
72
|
+
values: Record<string, unknown>;
|
|
73
|
+
/** 편집 대상 원본 카드 event_id(있으면). */
|
|
74
|
+
cardEventId?: string;
|
|
75
|
+
/** 호스트 주입 백엔드(있으면) — 권위 데이터 read-then-decide. 없으면 write-only(증명/오프라인). */
|
|
76
|
+
backend?: ActionBackend;
|
|
77
|
+
/**
|
|
78
|
+
* 호스트가 주입하는 앱 정책 설정(읽기 전용 데이터) — 예: finance 승인 임계값(approvalThreshold).
|
|
79
|
+
* 배포 설정(env 등)을 핸들러로 흘리는 의존성 주입 채널. 핸들러는 ctx.config?.<key> 로 읽는다(없으면 undefined).
|
|
80
|
+
*/
|
|
81
|
+
config?: Record<string, unknown>;
|
|
82
|
+
/** 효과 선언 헬퍼 — 원본 카드를 편집한다(m.replace). */
|
|
83
|
+
editCard(card: UiCardContent): void;
|
|
84
|
+
/** 효과 선언 헬퍼 — 새 카드를 보낸다. */
|
|
85
|
+
send(card: UiCardContent): void;
|
|
86
|
+
/** 효과 선언 헬퍼 — 엔티티 워크플로 상태를 갱신한다. */
|
|
87
|
+
workflow(status: string): void;
|
|
88
|
+
/** 효과 선언 헬퍼 — 다른 에이전트에게 위임(intent + payload). 호스트가 A2A 프로토콜로 실행한다. */
|
|
89
|
+
delegate(d: { intent: string; payload: Record<string, unknown> }): void;
|
|
90
|
+
/**
|
|
91
|
+
* 효과 선언 헬퍼 — 사실을 커스텀 이벤트로 발행한다(emit). 자기 앱 네임스페이스(org.corp.<app>.) 밖 타입은
|
|
92
|
+
* throw(소유 강제 — 남의 사실 위조 금지, 구독은 on 으로). events 에 선언된 타입이면 스키마를 outbound-strict
|
|
93
|
+
* 로 검증하고(불일치 throw), form:"message" 는 선언된 view 가 있어야 한다.
|
|
94
|
+
*/
|
|
95
|
+
emit(type: string, content: Record<string, unknown>, opts?: EmitOptions): void;
|
|
96
|
+
/**
|
|
97
|
+
* 효과 선언 헬퍼 — 다른 앱의 액션을 이름으로 호출한다(invoke = UI 없이 org.corp.ui.action 발행).
|
|
98
|
+
* 받는 쪽은 버튼에서 왔는지 봇이 보냈는지 구분하지 않는다. action_id 접두사(<app>.)가 라우팅 주소다.
|
|
99
|
+
*/
|
|
100
|
+
invoke(actionId: string, values?: Record<string, unknown>): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ActionHandler = (ctx: ActionCtx) => void | Promise<void>;
|
|
104
|
+
|
|
105
|
+
/** 호스트가 주입하는 권한 게이트(= appservice-agent/policy.evaluateAction 의 얇은 래퍼). */
|
|
106
|
+
export type GateFn = (input: {
|
|
107
|
+
senderId: string;
|
|
108
|
+
actionId: string;
|
|
109
|
+
requiredScope: string;
|
|
110
|
+
destructive: boolean;
|
|
111
|
+
powerLevels: unknown;
|
|
112
|
+
capability: unknown;
|
|
113
|
+
}) => { allowed: boolean; reason: string };
|
|
114
|
+
|
|
115
|
+
export interface DispatchInput {
|
|
116
|
+
app: App;
|
|
117
|
+
/** 들어온 org.corp.ui.action 의 content. */
|
|
118
|
+
actionContent: unknown;
|
|
119
|
+
/** 홈서버가 귀속한 발신자 MXID(권위 — 본문 from_* 아님). */
|
|
120
|
+
sender: string;
|
|
121
|
+
powerLevels?: unknown;
|
|
122
|
+
capability?: unknown;
|
|
123
|
+
cardEventId?: string;
|
|
124
|
+
entity?: { type?: string; id?: string };
|
|
125
|
+
gate: GateFn;
|
|
126
|
+
/** 호스트 주입 백엔드(선택) — 핸들러가 권위 데이터를 읽고 분기할 때 ctx.backend 로 전달된다. */
|
|
127
|
+
backend?: ActionBackend;
|
|
128
|
+
/** 호스트 주입 앱 정책 설정(선택) — ctx.config 로 핸들러에 전달(예: { approvalThreshold }). */
|
|
129
|
+
config?: Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface DispatchResult {
|
|
133
|
+
status: "ran" | "denied" | "ignored";
|
|
134
|
+
actionId?: string;
|
|
135
|
+
reason?: string;
|
|
136
|
+
effects: Effect[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 액션 한 건을 라우팅한다. 게이트는 호스트가 주입(보안 권위는 서버). 효과는 호스트가 적용. */
|
|
140
|
+
export async function dispatchAction(input: DispatchInput): Promise<DispatchResult> {
|
|
141
|
+
// 1) 발행 자격(SEC-3) — ui.action 은 human/agent 만(브리지 위조 거부). 서명된 sender 분류로 강제.
|
|
142
|
+
if (!canPublish(EventType.UiAction, classifySender(input.sender))) {
|
|
143
|
+
return { status: "ignored", reason: "publish_forbidden", effects: [] };
|
|
144
|
+
}
|
|
145
|
+
// 2) 레지스트리 검증 — 스키마 불일치/알 수 없는 타입이면 무시.
|
|
146
|
+
const v = validateEvent(EventType.UiAction, input.actionContent);
|
|
147
|
+
if (!v.ok) return { status: "ignored", reason: v.reason, effects: [] };
|
|
148
|
+
const action = v.data as { card_id: string; action_id: string; values?: Record<string, unknown> };
|
|
149
|
+
|
|
150
|
+
// 3) 앱이 아는 액션인가.
|
|
151
|
+
const def = input.app.actions.get(action.action_id);
|
|
152
|
+
if (!def) return { status: "ignored", reason: "unknown_action", effects: [] };
|
|
153
|
+
|
|
154
|
+
// 4) 권한 재검증 — 호스트 게이트에 액션 정의의 scope/destructive 를 데이터로 주입(서버가 최종 결정).
|
|
155
|
+
const decision = input.gate({
|
|
156
|
+
senderId: input.sender,
|
|
157
|
+
actionId: action.action_id,
|
|
158
|
+
requiredScope: def.scope,
|
|
159
|
+
destructive: def.destructive,
|
|
160
|
+
powerLevels: input.powerLevels,
|
|
161
|
+
capability: input.capability,
|
|
162
|
+
});
|
|
163
|
+
if (!decision.allowed) {
|
|
164
|
+
return { status: "denied", actionId: action.action_id, reason: decision.reason, effects: [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5) 핸들러 실행 — 효과 수집(부작용은 호스트가 적용).
|
|
168
|
+
const handler = input.app.handlers.get(action.action_id);
|
|
169
|
+
const effects: Effect[] = [];
|
|
170
|
+
if (handler) {
|
|
171
|
+
const publish = makePublishHelpers(input.app, effects);
|
|
172
|
+
const ctx: ActionCtx = {
|
|
173
|
+
sender: input.sender,
|
|
174
|
+
entity: input.entity ?? {},
|
|
175
|
+
cardId: action.card_id,
|
|
176
|
+
values: action.values ?? {},
|
|
177
|
+
cardEventId: input.cardEventId,
|
|
178
|
+
// backend 는 이 액션이 선언한 tools 화이트리스트로 감싼다(confused-deputy 방어, fail-closed).
|
|
179
|
+
backend: guardBackend(def, input.backend),
|
|
180
|
+
config: input.config,
|
|
181
|
+
editCard: (c) => effects.push({ kind: "edit", card: c }),
|
|
182
|
+
send: (c) => effects.push({ kind: "send", card: c }),
|
|
183
|
+
workflow: (status) => effects.push({ kind: "workflow", status }),
|
|
184
|
+
delegate: (d) => effects.push({ kind: "delegate", intent: d.intent, payload: d.payload }),
|
|
185
|
+
emit: publish.emit,
|
|
186
|
+
invoke: publish.invoke,
|
|
187
|
+
};
|
|
188
|
+
await handler(ctx);
|
|
189
|
+
}
|
|
190
|
+
return { status: "ran", actionId: action.action_id, effects };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// 발행 헬퍼(emit/invoke) — ActionCtx/EventCtx 가 공유하는 효과 수집기
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* emit/invoke 효과 수집기를 만든다 — 두 컨텍스트(액션 핸들러·구독 핸들러)가 같은 규칙을 공유한다.
|
|
199
|
+
* emit 은 정의 시점이 아니라 *핸들러 실행 시점*에만 검증할 수 있으므로(타입/내용이 동적) 여기서 fail-fast throw
|
|
200
|
+
* 한다(outbound-strict — buildUiMessageContent 가 잘못된 블록을 거부하는 것과 같은 자세).
|
|
201
|
+
*/
|
|
202
|
+
function makePublishHelpers(
|
|
203
|
+
app: App,
|
|
204
|
+
effects: Effect[],
|
|
205
|
+
): { emit: ActionCtx["emit"]; invoke: ActionCtx["invoke"] } {
|
|
206
|
+
return {
|
|
207
|
+
emit(type, content, opts) {
|
|
208
|
+
const form: EmitForm = opts?.form ?? "event";
|
|
209
|
+
// 소유 강제 — 이벤트 등록(register)·액션 id(assertActionNamespace)와 같은 규칙을 emit 에도 건다.
|
|
210
|
+
const prefix = `${NS}.${app.app}.`;
|
|
211
|
+
if (!type.startsWith(prefix)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`[app-sdk] 앱 '${app.app}': emit 타입 '${type}' 가 앱 네임스페이스 '${prefix}' 밖입니다 — ` +
|
|
214
|
+
`자기 사실만 발행할 수 있습니다(남의 이벤트에 반응하려면 on 구독을 쓰세요).`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const declared = app.events[type];
|
|
218
|
+
let out = content;
|
|
219
|
+
if (declared) {
|
|
220
|
+
// 선언된 타입은 outbound-strict — 스키마 불일치를 발행 전에 잡는다(구독자가 쓰레기를 받지 않게).
|
|
221
|
+
const parsed = declared.schema.safeParse(content);
|
|
222
|
+
if (!parsed.success) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`[app-sdk] 앱 '${app.app}': emit('${type}') content 가 선언 스키마와 불일치 — ${parsed.error.issues
|
|
225
|
+
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
226
|
+
.join("; ")}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
out = parsed.data as Record<string, unknown>;
|
|
230
|
+
} else if (form === "message") {
|
|
231
|
+
// message 형태는 카드를 그릴 view 가 필요하다 — 선언 없는 타입이면 그릴 방법이 없으니 즉시 알린다.
|
|
232
|
+
throw new Error(
|
|
233
|
+
`[app-sdk] 앱 '${app.app}': emit('${type}', { form: "message" }) 는 events 에 선언된(view 있는) 타입만 가능합니다.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
effects.push({
|
|
237
|
+
kind: "emit",
|
|
238
|
+
type,
|
|
239
|
+
content: out,
|
|
240
|
+
form,
|
|
241
|
+
...(form === "state" ? { stateKey: opts?.stateKey ?? "" } : {}),
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
invoke(actionId, values) {
|
|
245
|
+
// 주소(<app>. 접두사) 없는 id 는 라우팅될 수 없다 — assertActionNamespace 가 보장하는 형태를 요구한다.
|
|
246
|
+
if (!actionId.includes(".")) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[app-sdk] invoke: action_id '${actionId}' 는 '<app>.<action>' 형태여야 합니다(접두사 = 라우팅 주소).`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
effects.push({ kind: "invoke", actionId, values: values ?? {} });
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 액션이 선언한 tools 화이트리스트로 호스트 backend 를 감싼다(confused-deputy 방어).
|
|
258
|
+
* 핸들러가 ctx.backend.call(tool) 로 부를 수 있는 도구를 def.tools 로만 좁힌다 — 미선언/빈 배열이면
|
|
259
|
+
* 어떤 도구도 거부(fail-closed). 선언 밖 도구를 부르면 throw(1st-party 핸들러의 도구 드리프트/사고를 즉시 가시화).
|
|
260
|
+
* 강제는 SDK 가 기계적으로(선언 일치 검사), 도구 구현·권한은 호스트가 소유(의존성 역전, GateFn 과 같은 자세).
|
|
261
|
+
*/
|
|
262
|
+
function guardBackend(def: AppAction, raw: ActionBackend | undefined): ActionBackend | undefined {
|
|
263
|
+
if (!raw) return undefined;
|
|
264
|
+
const allowed = new Set(def.tools ?? []);
|
|
265
|
+
return {
|
|
266
|
+
call(tool, args) {
|
|
267
|
+
if (!allowed.has(tool)) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[app-sdk] 액션 '${def.id}' 가 미선언 도구 '${tool}' 를 호출했습니다 — ` +
|
|
270
|
+
`defineAction({ tools: [...] }) 에 선언해야 합니다(confused-deputy 방지, fail-closed).`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return raw.call(tool, args);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// 구독 디스패치 (on) — "남이 낸 이벤트를 듣는다"
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/** 구독 핸들러가 받는 컨텍스트 — 반응은 효과(send/emit/invoke)로만 선언한다(부작용은 호스트가 적용). */
|
|
283
|
+
export interface EventCtx {
|
|
284
|
+
/** 구독한 이벤트 타입. */
|
|
285
|
+
type: string;
|
|
286
|
+
/** 이벤트가 발생한 방. */
|
|
287
|
+
roomId: string;
|
|
288
|
+
/** 홈서버가 귀속한 발신자 MXID(권위 — 본문 from_* 아님). */
|
|
289
|
+
sender: string;
|
|
290
|
+
/** 이벤트 content(레지스트리가 아는 타입이면 파싱된 값, 모르면 원본). 구독 앱이 자기 스키마로 parse 해 타입 확보. */
|
|
291
|
+
content: Record<string, unknown>;
|
|
292
|
+
/** 원본 이벤트 id(있으면). */
|
|
293
|
+
eventId?: string;
|
|
294
|
+
/** 효과 선언 헬퍼 — 새 카드를 보낸다. */
|
|
295
|
+
send(card: UiCardContent): void;
|
|
296
|
+
/** 효과 선언 헬퍼 — 자기 사실을 발행한다(규칙은 ActionCtx.emit 과 동일). */
|
|
297
|
+
emit(type: string, content: Record<string, unknown>, opts?: EmitOptions): void;
|
|
298
|
+
/** 효과 선언 헬퍼 — 다른 앱의 액션을 호출한다(규칙은 ActionCtx.invoke 와 동일). */
|
|
299
|
+
invoke(actionId: string, values?: Record<string, unknown>): void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export type EventHandler = (ctx: EventCtx) => void | Promise<void>;
|
|
303
|
+
|
|
304
|
+
export interface DispatchEventInput {
|
|
305
|
+
app: App;
|
|
306
|
+
/** 들어온 이벤트 타입. */
|
|
307
|
+
type: string;
|
|
308
|
+
/** 들어온 이벤트 content. */
|
|
309
|
+
content: unknown;
|
|
310
|
+
/** 홈서버가 귀속한 발신자 MXID. */
|
|
311
|
+
sender: string;
|
|
312
|
+
roomId: string;
|
|
313
|
+
eventId?: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface DispatchEventResult {
|
|
317
|
+
status: "ran" | "ignored";
|
|
318
|
+
reason?: string;
|
|
319
|
+
effects: Effect[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 구독 이벤트 한 건을 라우팅한다 — 구독하지 않은 타입은 조용히 넘긴다(ignored, 거부 메시지 없음).
|
|
324
|
+
*
|
|
325
|
+
* 검증은 "로컬 지식 한정": 이 프로세스의 레지스트리가 아는 타입이면 스키마 불일치를 거르고(schema_mismatch),
|
|
326
|
+
* 모르는 타입(외부 앱의 사실)은 원본 그대로 핸들러에 넘긴다 — Matrix 는 임의 이벤트를 막지 못하므로 레지스트리는
|
|
327
|
+
* 강제가 아니라 합의 도구다(킥오프 §5). 발행 자격(canPublish) 게이트도 걸지 않는다 — 외부 봇은 human 으로
|
|
328
|
+
* 분류되어 정당한 연쇄가 막히기 때문에, 발신자 신뢰 판단은 sender 를 보고 호스트/핸들러가 한다.
|
|
329
|
+
*
|
|
330
|
+
* ⚠️ 루프 방지는 호스트 책임 — 자기(봇 계정)가 보낸 이벤트는 dispatchEvent 에 넣지 말 것(emit→on 무한 연쇄).
|
|
331
|
+
*/
|
|
332
|
+
export async function dispatchEvent(input: DispatchEventInput): Promise<DispatchEventResult> {
|
|
333
|
+
const handler = input.app.on.get(input.type);
|
|
334
|
+
if (!handler) return { status: "ignored", reason: "not_subscribed", effects: [] };
|
|
335
|
+
const v = validateEvent(input.type, input.content);
|
|
336
|
+
if (!v.ok && v.reason !== "unknown_type") return { status: "ignored", reason: v.reason, effects: [] };
|
|
337
|
+
const effects: Effect[] = [];
|
|
338
|
+
const publish = makePublishHelpers(input.app, effects);
|
|
339
|
+
const ctx: EventCtx = {
|
|
340
|
+
type: input.type,
|
|
341
|
+
roomId: input.roomId,
|
|
342
|
+
sender: input.sender,
|
|
343
|
+
content: (v.ok ? v.data : input.content) as Record<string, unknown>,
|
|
344
|
+
eventId: input.eventId,
|
|
345
|
+
send: (c) => effects.push({ kind: "send", card: c }),
|
|
346
|
+
emit: publish.emit,
|
|
347
|
+
invoke: publish.invoke,
|
|
348
|
+
};
|
|
349
|
+
await handler(ctx);
|
|
350
|
+
return { status: "ran", effects };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// 효과 적용기 — 호스트 무관 순수 배선(agent.ts 에서 추출, Phase 1)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 호스트가 주입하는 최소 Matrix 전송 핸들 — matrix-bot-sdk 의 MatrixClient(및 Intent.underlyingClient)가
|
|
359
|
+
* 구조적으로 그대로 만족한다. SDK 는 이 두 메서드 외 어떤 클라이언트 능력도 요구하지 않는다.
|
|
360
|
+
*/
|
|
361
|
+
export interface MatrixSendClient {
|
|
362
|
+
sendEvent(roomId: string, eventType: string, content: Record<string, unknown>): Promise<unknown>;
|
|
363
|
+
sendStateEvent(roomId: string, eventType: string, stateKey: string, content: Record<string, unknown>): Promise<unknown>;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface ApplyEffectsInput {
|
|
367
|
+
/** 전송 핸들(호스트 주입) — 봇 계정 클라이언트든 AS Intent 든 무관. */
|
|
368
|
+
client: MatrixSendClient;
|
|
369
|
+
/** 효과를 선언한 앱 — emit(form:"message") 의 view 해석과 invoke 의 발신 표기(card_id)에 쓴다. */
|
|
370
|
+
app: App;
|
|
371
|
+
roomId: string;
|
|
372
|
+
effects: Effect[];
|
|
373
|
+
/** edit 효과의 원본 카드 event_id — 없으면 새 카드로 graceful(참조 유실 대비). */
|
|
374
|
+
cardEventId?: string;
|
|
375
|
+
/** workflow 효과의 엔티티(state_key=entity.id). id 없으면 workflow 는 경고 후 생략. */
|
|
376
|
+
entity?: { type?: string; id?: string };
|
|
377
|
+
/** workflow.updated_by 로 기록할 주체(보통 액션 발신자). */
|
|
378
|
+
sender?: string;
|
|
379
|
+
/**
|
|
380
|
+
* delegate 효과 콜백 — stateful·교차에이전트(A2A) 프로토콜은 호스트가 소유한다. 미주입 호스트(1회 발행·
|
|
381
|
+
* 단순 봇)에서 delegate 가 나오면 경고 후 무시한다(조용한 유실 방지 로그).
|
|
382
|
+
*/
|
|
383
|
+
onDelegate?: (eff: Extract<Effect, { kind: "delegate" }>) => void | Promise<void>;
|
|
384
|
+
/** 진단 로그 훅(기본 console.warn/error) — 증명/테스트가 경고 경로를 관찰할 때 주입. */
|
|
385
|
+
warn?: (msg: string) => void;
|
|
386
|
+
error?: (msg: string) => void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 핸들러가 선언한 효과를 Matrix 로 적용한다 — 호스트 무관(AS 에이전트·봇 계정·증명 스크립트가 같은 코드 사용).
|
|
391
|
+
* 배열 순서대로 적용한다. edit/send/emit/invoke 전송 실패는 throw(호스트가 회복 정책 소유),
|
|
392
|
+
* workflow 는 상태 이벤트 특성상 로그 후 계속(agent 레거시 동작 보존 — 카드 갱신을 막지 않는다).
|
|
393
|
+
*/
|
|
394
|
+
export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
|
|
395
|
+
// SDK 는 런타임 환경을 가정하지 않는다(lib 에 console 없음) — 기본 로그는 globalThis.console 이 있을 때만.
|
|
396
|
+
const g = globalThis as { console?: { warn(m: string): void; error(m: string): void } };
|
|
397
|
+
const warn = input.warn ?? ((m: string) => g.console?.warn(m));
|
|
398
|
+
const error = input.error ?? ((m: string) => g.console?.error(m));
|
|
399
|
+
for (const e of input.effects) {
|
|
400
|
+
switch (e.kind) {
|
|
401
|
+
case "edit": {
|
|
402
|
+
// 편집 대상 카드가 있으면 m.replace, 없으면(참조 유실) 새 카드로 graceful.
|
|
403
|
+
const msg = input.cardEventId ? buildUiMessageEdit(input.cardEventId, e.card) : buildUiMessageContent(e.card);
|
|
404
|
+
await input.client.sendEvent(input.roomId, "m.room.message", msg);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case "send":
|
|
408
|
+
await input.client.sendEvent(input.roomId, "m.room.message", buildUiMessageContent(e.card));
|
|
409
|
+
break;
|
|
410
|
+
case "workflow": {
|
|
411
|
+
if (!input.entity?.id) {
|
|
412
|
+
warn(`[app-sdk] workflow 효과 생략 — 엔티티 id 미해석 (status=${e.status})`);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
const content: WorkflowStateContent = {
|
|
416
|
+
v: 1,
|
|
417
|
+
status: e.status,
|
|
418
|
+
entity: { type: input.entity.type ?? "entity", id: input.entity.id },
|
|
419
|
+
updated_by: input.sender,
|
|
420
|
+
updated_at: Date.now(),
|
|
421
|
+
};
|
|
422
|
+
try {
|
|
423
|
+
await input.client.sendStateEvent(input.roomId, EventType.WorkflowState, input.entity.id, content);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
error(`[app-sdk] workflow.state 게시 실패 (${input.entity.id}, ${e.status}): ${err instanceof Error ? err.message : String(err)}`);
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "delegate":
|
|
430
|
+
if (input.onDelegate) await input.onDelegate(e);
|
|
431
|
+
else warn(`[app-sdk] delegate 효과 무시 — 호스트가 onDelegate 를 주입하지 않았습니다 (intent=${e.intent})`);
|
|
432
|
+
break;
|
|
433
|
+
case "emit": {
|
|
434
|
+
if (e.form === "message") {
|
|
435
|
+
// 선언 view 로 카드를 그려 m.room.message 로 — emit 시점 검증을 통과한 content 라 보통 성공한다.
|
|
436
|
+
const msg = input.app.messageForEvent(e.type, e.content);
|
|
437
|
+
if (!msg) {
|
|
438
|
+
warn(`[app-sdk] emit(message) 생략 — '${e.type}' 의 view/스키마 해석 실패`);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
await input.client.sendEvent(input.roomId, "m.room.message", msg);
|
|
442
|
+
} else if (e.form === "state") {
|
|
443
|
+
await input.client.sendStateEvent(input.roomId, e.type, e.stateKey ?? "", e.content);
|
|
444
|
+
} else {
|
|
445
|
+
await input.client.sendEvent(input.roomId, e.type, e.content);
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case "invoke": {
|
|
450
|
+
// UI 없는 호출 — 카드가 없으므로 card_id 는 발신 앱 표기(수신 라우팅은 action_id 접두사가 한다).
|
|
451
|
+
const content = buildUiActionContent({
|
|
452
|
+
card_id: `invoke:${input.app.app}`,
|
|
453
|
+
action_id: e.actionId,
|
|
454
|
+
action_type: "submit",
|
|
455
|
+
values: e.values,
|
|
456
|
+
});
|
|
457
|
+
await input.client.sendEvent(input.roomId, EventType.UiAction, content as unknown as Record<string, unknown>);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|