@things-factory/ai-assistant 10.1.38 → 10.1.40

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.
Files changed (37) hide show
  1. package/client/components/assistant-chat.ts +30 -1
  2. package/client/utils/assistant-request-context.ts +32 -0
  3. package/dist-client/components/assistant-chat.d.ts +12 -0
  4. package/dist-client/components/assistant-chat.js +24 -1
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/tsconfig.tsbuildinfo +1 -1
  7. package/dist-client/utils/assistant-request-context.d.ts +23 -0
  8. package/dist-client/utils/assistant-request-context.js +25 -0
  9. package/dist-client/utils/assistant-request-context.js.map +1 -1
  10. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +6 -6
  11. package/dist-server/service/chat-message/chat-message-broadcast.js +5 -5
  12. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -1
  13. package/dist-server/service/chat-message/chat-message-room.d.ts +3 -3
  14. package/dist-server/service/chat-message/chat-message-room.js +10 -7
  15. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  16. package/dist-server/service/chat-message/chat-message-subscription.js +6 -5
  17. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  18. package/dist-server/service/chat-session/chat-session.js +1 -1
  19. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  20. package/dist-server/service/chat-session/session-anchor.d.ts +5 -1
  21. package/dist-server/service/chat-session/session-anchor.js +23 -1
  22. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  23. package/dist-server/service/testing/mode-conformance.js +6 -5
  24. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +5 -5
  27. package/server/service/chat-message/chat-message-broadcast.ts +15 -13
  28. package/server/service/chat-message/chat-message-room.ts +10 -7
  29. package/server/service/chat-message/chat-message-subscription.ts +6 -5
  30. package/server/service/chat-session/chat-session.ts +1 -1
  31. package/server/service/chat-session/session-anchor.ts +26 -1
  32. package/server/service/session-anchor.test.ts +55 -1
  33. package/server/service/testing/conversation-script.test.ts +1 -1
  34. package/server/service/testing/mode-conformance.test.ts +2 -2
  35. package/server/service/testing/mode-conformance.ts +6 -5
  36. package/test/assistant-request-context.test.ts +41 -1
  37. package/test/open-doors.test.ts +132 -0
@@ -5,6 +5,29 @@ export interface AssistantRequestContext {
5
5
  hostContext?: Record<string, unknown>;
6
6
  }
7
7
  export declare function assistantAutoAskKey(text: string, requestId?: string): string;
8
+ /**
9
+ * Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.
10
+ *
11
+ * A host sometimes wants a sentence placed in the input for the person to edit and send themselves
12
+ * ("new twin → AI" in twin's instance list). Four rules, each a thing a person would notice:
13
+ * · once per request — a re-render must not fill it again after the person cleared or changed it
14
+ * · never over what the person is typing — their text is theirs
15
+ * · when `autoAsk` comes for the same request, it wins — sending is the clearer intent
16
+ * · no draft, nothing to do
17
+ * Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a
18
+ * skipped draft does not surface later by surprise.
19
+ */
20
+ export declare function assistantDraftDecision(input: {
21
+ draft?: string;
22
+ draftRequestId?: string;
23
+ drafted?: string;
24
+ typed: string;
25
+ autoAsk?: string;
26
+ autoAskRequestId?: string;
27
+ }): {
28
+ fill: boolean;
29
+ consumed?: string;
30
+ };
8
31
  export declare function assistantRecentSessions<T extends {
9
32
  id: string;
10
33
  }>(sessions: T[], selected: string, limit: number): T[];
@@ -9,6 +9,31 @@ export function assistantModelFingerprint(model) {
9
9
  export function assistantAutoAskKey(text, requestId) {
10
10
  return requestId ? JSON.stringify([requestId, text]) : text;
11
11
  }
12
+ /**
13
+ * Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.
14
+ *
15
+ * A host sometimes wants a sentence placed in the input for the person to edit and send themselves
16
+ * ("new twin → AI" in twin's instance list). Four rules, each a thing a person would notice:
17
+ * · once per request — a re-render must not fill it again after the person cleared or changed it
18
+ * · never over what the person is typing — their text is theirs
19
+ * · when `autoAsk` comes for the same request, it wins — sending is the clearer intent
20
+ * · no draft, nothing to do
21
+ * Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a
22
+ * skipped draft does not surface later by surprise.
23
+ */
24
+ export function assistantDraftDecision(input) {
25
+ if (!input.draft)
26
+ return { fill: false };
27
+ const key = assistantAutoAskKey(input.draft, input.draftRequestId);
28
+ if (key === input.drafted)
29
+ return { fill: false };
30
+ const sameRequestAsked = !!input.autoAsk && !!input.draftRequestId && input.autoAskRequestId === input.draftRequestId;
31
+ if (sameRequestAsked)
32
+ return { fill: false, consumed: key };
33
+ if (input.typed.trim())
34
+ return { fill: false, consumed: key };
35
+ return { fill: true, consumed: key };
36
+ }
12
37
  export function assistantRecentSessions(sessions, selected, limit) {
13
38
  const count = Math.max(1, Math.floor(limit) || 5);
14
39
  const recent = sessions.slice(0, count);
@@ -1 +1 @@
1
- {"version":3,"file":"assistant-request-context.js","sourceRoot":"","sources":["../../client/utils/assistant-request-context.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACzC,MAAM,SAAS,GAAG,CAAC,KAAU,EAAO,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC;QAChF,CAAC,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IACvI,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;AACzC,CAAC;AAKD,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,SAAkB;IAClE,OAAO,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC7D,CAAC;AACD,MAAM,UAAU,uBAAuB,CAA2B,QAAa,EAAE,QAAgB,EAAE,KAAa;IAC9G,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;IACjD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;IACvC,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IAChE,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,CAAA;IAClG,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Exact deterministic model signature, not a security hash. Only plain JSON models are supported. */\nexport function assistantModelFingerprint(model: unknown): string | undefined {\n if (model === undefined) return undefined\n const normalize = (value: any): any => Array.isArray(value) ? value.map(normalize)\n : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value\n return JSON.stringify(normalize(model))\n}\nexport interface AssistantRequestContext {\n systemPrompt?: string\n hostContext?: Record<string, unknown>\n}\nexport function assistantAutoAskKey(text: string, requestId?: string): string {\n return requestId ? JSON.stringify([requestId, text]) : text\n}\nexport function assistantRecentSessions<T extends { id: string }>(sessions: T[], selected: string, limit: number): T[] {\n const count = Math.max(1, Math.floor(limit) || 5)\n const recent = sessions.slice(0, count)\n const active = sessions.find(session => session.id === selected)\n if (active && !recent.some(session => session.id === selected)) recent[recent.length - 1] = active\n return recent\n}\n"]}
1
+ {"version":3,"file":"assistant-request-context.js","sourceRoot":"","sources":["../../client/utils/assistant-request-context.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACzC,MAAM,SAAS,GAAG,CAAC,KAAU,EAAO,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC;QAChF,CAAC,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IACvI,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;AACzC,CAAC;AAKD,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,SAAkB;IAClE,OAAO,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC7D,CAAC;AACD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CAAC,KAOtC;IACC,IAAI,CAAC,KAAK,CAAC,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IAExC,MAAM,GAAG,GAAG,mBAAmB,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC,CAAA;IAClE,IAAI,GAAG,KAAK,KAAK,CAAC,OAAO;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IAEjD,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,cAAc,IAAI,KAAK,CAAC,gBAAgB,KAAK,KAAK,CAAC,cAAc,CAAA;IACrH,IAAI,gBAAgB;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;IAE3D,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;IAE7D,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;AACtC,CAAC;AACD,MAAM,UAAU,uBAAuB,CAA2B,QAAa,EAAE,QAAgB,EAAE,KAAa;IAC9G,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;IACjD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;IACvC,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IAChE,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,CAAA;IAClG,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Exact deterministic model signature, not a security hash. Only plain JSON models are supported. */\nexport function assistantModelFingerprint(model: unknown): string | undefined {\n if (model === undefined) return undefined\n const normalize = (value: any): any => Array.isArray(value) ? value.map(normalize)\n : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value\n return JSON.stringify(normalize(model))\n}\nexport interface AssistantRequestContext {\n systemPrompt?: string\n hostContext?: Record<string, unknown>\n}\nexport function assistantAutoAskKey(text: string, requestId?: string): string {\n return requestId ? JSON.stringify([requestId, text]) : text\n}\n/**\n * Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.\n *\n * A host sometimes wants a sentence placed in the input for the person to edit and send themselves\n * (\"new twin → AI\" in twin's instance list). Four rules, each a thing a person would notice:\n * · once per request — a re-render must not fill it again after the person cleared or changed it\n * · never over what the person is typing — their text is theirs\n * · when `autoAsk` comes for the same request, it wins — sending is the clearer intent\n * · no draft, nothing to do\n * Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a\n * skipped draft does not surface later by surprise.\n */\nexport function assistantDraftDecision(input: {\n draft?: string\n draftRequestId?: string\n drafted?: string\n typed: string\n autoAsk?: string\n autoAskRequestId?: string\n}): { fill: boolean; consumed?: string } {\n if (!input.draft) return { fill: false }\n\n const key = assistantAutoAskKey(input.draft, input.draftRequestId)\n if (key === input.drafted) return { fill: false }\n\n const sameRequestAsked = !!input.autoAsk && !!input.draftRequestId && input.autoAskRequestId === input.draftRequestId\n if (sameRequestAsked) return { fill: false, consumed: key }\n\n if (input.typed.trim()) return { fill: false, consumed: key }\n\n return { fill: true, consumed: key }\n}\nexport function assistantRecentSessions<T extends { id: string }>(sessions: T[], selected: string, limit: number): T[] {\n const count = Math.max(1, Math.floor(limit) || 5)\n const recent = sessions.slice(0, count)\n const active = sessions.find(session => session.id === selected)\n if (active && !recent.some(session => session.id === selected)) recent[recent.length - 1] = active\n return recent\n}\n"]}
@@ -11,18 +11,18 @@ export interface ChatMessagePayload {
11
11
  * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).
12
12
  */
13
13
  export declare function chatMessageFilter(domainId: string, sessionId: string): (payload: ChatMessagePayload) => boolean;
14
- /** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */
14
+ /** The two questions about a room. The caller supplies the storage — this file does not know it. */
15
15
  export interface ChatRoomLookup {
16
- /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */
16
+ /** The session's visibility now; `undefined` when this domain has no such session. */
17
17
  visibility(): Promise<string | null | undefined>;
18
- /** 부르는 사람이 지금 그 세션의 참여자인가. */
18
+ /** Whether the caller sits in that session now. */
19
19
  isParticipant(): Promise<boolean>;
20
20
  }
21
21
  export declare function mayHearRoom(room: ChatRoomLookup): Promise<boolean>;
22
22
  /**
23
- * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
23
+ * Passes only the same domain + session, and only while **that room is open to this person**.
24
24
  *
25
- * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
26
- * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.
25
+ * The room is asked again for every message — a session can be made private while someone is
26
+ * listening. People write these messages, so they are infrequent, and an open room costs one query.
27
27
  */
28
28
  export declare function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup): (payload: ChatMessagePayload) => Promise<boolean>;
@@ -15,19 +15,19 @@ function chatMessageFilter(domainId, sessionId) {
15
15
  }
16
16
  async function mayHearRoom(room) {
17
17
  const visibility = await room.visibility();
18
- /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */
18
+ /* A session this domain does not have — never read as open. */
19
19
  if (visibility === undefined)
20
20
  return false;
21
- /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */
21
+ /* An open room does not ask about membership — one query fewer per message. */
22
22
  if ((0, session_visibility_js_1.mayReadSession)(visibility, false))
23
23
  return true;
24
24
  return (0, session_visibility_js_1.mayReadSession)(visibility, await room.isParticipant());
25
25
  }
26
26
  /**
27
- * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
27
+ * Passes only the same domain + session, and only while **that room is open to this person**.
28
28
  *
29
- * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
30
- * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.
29
+ * The room is asked again for every message — a session can be made private while someone is
30
+ * listening. People write these messages, so they are infrequent, and an open room costs one query.
31
31
  */
32
32
  function chatMessageRoomFilter(domainId, sessionId, room) {
33
33
  const sameSession = chatMessageFilter(domainId, sessionId);
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-broadcast.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-broadcast.ts"],"names":[],"mappings":";;;AAwBA,8CAGC;AAmBD,kCAUC;AAQD,sDAGC;AA1DD,iFAAsE;AAEtE,8BAA8B;AACjB,QAAA,kBAAkB,GAAG,uBAAuB,CAAA;AASzD;;GAEG;AACH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB;IACnE,OAAO,CAAC,OAA2B,EAAW,EAAE,CAC9C,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,CAAA;AACjF,CAAC;AAmBM,KAAK,UAAU,WAAW,CAAC,IAAoB;IACpD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAA;IAE1C,iCAAiC;IACjC,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAE1C,2CAA2C;IAC3C,IAAI,IAAA,sCAAc,EAAC,UAAU,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAElD,OAAO,IAAA,sCAAc,EAAC,UAAU,EAAE,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,qBAAqB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IAC7F,MAAM,WAAW,GAAG,iBAAiB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAA;IAC1D,OAAO,KAAK,EAAE,OAA2B,EAAoB,EAAE,CAAC,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC,CAAA;AACnH,CAAC","sourcesContent":["/*\n * chat-message 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음\n * → 단위테스트 가능. 데코레이터 resolver(chat-message-subscription.ts)와 pubsub 의존\n * publishChatMessage 는 별도.\n *\n * patch spine(board-patch-broadcast.ts)과 동일 컨벤션. 다만 채팅 메시지는 board 가 아니라\n * **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).\n */\nimport type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님\nimport { mayReadSession } from '../chat-session/session-visibility.js'\n\n/** chat-message 브로드캐스트 토픽. */\nexport const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'\n\n/** 방송 페이로드 — 구독 필터(domain+session)용 메타 + ChatMessage 본체. */\nexport interface ChatMessagePayload {\n domainId: string\n sessionId?: string\n message: ChatMessage\n}\n\n/**\n * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).\n */\nexport function chatMessageFilter(domainId: string, sessionId: string) {\n return (payload: ChatMessagePayload): boolean =>\n !!payload && payload.domainId === domainId && payload.sessionId === sessionId\n}\n\n/*\n * ── 방이 이 사람에게 열려 있나 — ADR-0073 결정 2 ────────────────────────────\n *\n * 공개 대화는 도메인 안 누구나, 비공개 대화는 참여자만. 저장된 길(`chatMessages`)은 그것을 묻고\n * 있었고 방송 길은 묻지 않았다 — 구독 때 도메인 소속만 보고, 필터는 도메인 + 세션만 맞췄다. 그래서\n * 비공개 세션의 id 를 가진 도메인 안 누구나 그 대화를 실시간으로 받았다.\n *\n * 규칙은 한 줄(`mayReadSession`)이고 두 길이 같은 줄을 읽는다. 여기서 다시 적으면 그날부터 두 벌이다.\n */\n/** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */\nexport interface ChatRoomLookup {\n /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */\n visibility(): Promise<string | null | undefined>\n /** 부르는 사람이 지금 그 세션의 참여자인가. */\n isParticipant(): Promise<boolean>\n}\n\nexport async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {\n const visibility = await room.visibility()\n\n /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */\n if (visibility === undefined) return false\n\n /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */\n if (mayReadSession(visibility, false)) return true\n\n return mayReadSession(visibility, await room.isParticipant())\n}\n\n/**\n * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.\n *\n * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라\n * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.\n */\nexport function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {\n const sameSession = chatMessageFilter(domainId, sessionId)\n return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))\n}\n"]}
1
+ {"version":3,"file":"chat-message-broadcast.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-broadcast.ts"],"names":[],"mappings":";;;AAwBA,8CAGC;AAqBD,kCAUC;AAQD,sDAGC;AA5DD,iFAAsE;AAEtE,8BAA8B;AACjB,QAAA,kBAAkB,GAAG,uBAAuB,CAAA;AASzD;;GAEG;AACH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB;IACnE,OAAO,CAAC,OAA2B,EAAW,EAAE,CAC9C,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,CAAA;AACjF,CAAC;AAqBM,KAAK,UAAU,WAAW,CAAC,IAAoB;IACpD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAA;IAE1C,+DAA+D;IAC/D,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAE1C,+EAA+E;IAC/E,IAAI,IAAA,sCAAc,EAAC,UAAU,EAAE,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAElD,OAAO,IAAA,sCAAc,EAAC,UAAU,EAAE,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,qBAAqB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IAC7F,MAAM,WAAW,GAAG,iBAAiB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAA;IAC1D,OAAO,KAAK,EAAE,OAA2B,EAAoB,EAAE,CAAC,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC,CAAA;AACnH,CAAC","sourcesContent":["/*\n * chat-message 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음\n * → 단위테스트 가능. 데코레이터 resolver(chat-message-subscription.ts)와 pubsub 의존\n * publishChatMessage 는 별도.\n *\n * patch spine(board-patch-broadcast.ts)과 동일 컨벤션. 다만 채팅 메시지는 board 가 아니라\n * **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).\n */\nimport type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님\nimport { mayReadSession } from '../chat-session/session-visibility.js'\n\n/** chat-message 브로드캐스트 토픽. */\nexport const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'\n\n/** 방송 페이로드 — 구독 필터(domain+session)용 메타 + ChatMessage 본체. */\nexport interface ChatMessagePayload {\n domainId: string\n sessionId?: string\n message: ChatMessage\n}\n\n/**\n * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).\n */\nexport function chatMessageFilter(domainId: string, sessionId: string) {\n return (payload: ChatMessagePayload): boolean =>\n !!payload && payload.domainId === domainId && payload.sessionId === sessionId\n}\n\n/*\n * ── Is the room open to this person — ADR-0073 decision 2 ─────────────────────\n *\n * An open conversation is readable by anyone in the domain, a private one by its participants. The\n * stored path (`chatMessages`) asked that and the broadcast path did not — subscribing checked only\n * domain membership, and the filter matched only domain + session. So anyone in the domain holding a\n * private session's id received that conversation as it was written.\n *\n * The rule is one line (`mayReadSession`) and both paths read that line. Writing it again here would\n * make two copies from that day on.\n */\n/** The two questions about a room. The caller supplies the storage — this file does not know it. */\nexport interface ChatRoomLookup {\n /** The session's visibility now; `undefined` when this domain has no such session. */\n visibility(): Promise<string | null | undefined>\n /** Whether the caller sits in that session now. */\n isParticipant(): Promise<boolean>\n}\n\nexport async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {\n const visibility = await room.visibility()\n\n /* A session this domain does not have — never read as open. */\n if (visibility === undefined) return false\n\n /* An open room does not ask about membership — one query fewer per message. */\n if (mayReadSession(visibility, false)) return true\n\n return mayReadSession(visibility, await room.isParticipant())\n}\n\n/**\n * Passes only the same domain + session, and only while **that room is open to this person**.\n *\n * The room is asked again for every message — a session can be made private while someone is\n * listening. People write these messages, so they are infrequent, and an open room costs one query.\n */\nexport function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {\n const sameSession = chatMessageFilter(domainId, sessionId)\n return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))\n}\n"]}
@@ -6,8 +6,8 @@ interface FindOne {
6
6
  }): Promise<any>;
7
7
  }
8
8
  /**
9
- * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
10
- * 같은 조건이다.
9
+ * The two lookups about a room. **Both search within the caller's domain** — the same condition as
10
+ * the stored path's query (`chatMessages`).
11
11
  */
12
12
  export declare function chatRoomLookup(repos: {
13
13
  sessions: FindOne;
@@ -17,6 +17,6 @@ export declare function chatRoomLookup(repos: {
17
17
  sessionId: string;
18
18
  userId?: string;
19
19
  }): ChatRoomLookup;
20
- /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
20
+ /** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
21
21
  export declare function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup): import("graphql-yoga").Repeater<import("./chat-message-broadcast.js").ChatMessagePayload, void, unknown>;
22
22
  export {};
@@ -3,19 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.chatRoomLookup = chatRoomLookup;
4
4
  exports.chatMessageStream = chatMessageStream;
5
5
  /*
6
- * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.
6
+ * The room on the broadcast path — the stream the subscription returns, and the two lookups that
7
+ * ask about the room.
7
8
  *
8
- * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을
9
- * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).
9
+ * jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the
10
+ * stream lives here — the test runs **the same thing** the resolver returns
11
+ * (`chat-message-room.test.ts`).
10
12
  *
11
- * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.
13
+ * The caller passes the storage in. Importing the entities here would make this file unreadable to
14
+ * the tests too.
12
15
  */
13
16
  const graphql_yoga_1 = require("graphql-yoga");
14
17
  const shell_1 = require("@things-factory/shell");
15
18
  const chat_message_broadcast_js_1 = require("./chat-message-broadcast.js");
16
19
  /**
17
- * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
18
- * 같은 조건이다.
20
+ * The two lookups about a room. **Both search within the caller's domain** — the same condition as
21
+ * the stored path's query (`chatMessages`).
19
22
  */
20
23
  function chatRoomLookup(repos, who) {
21
24
  return {
@@ -33,7 +36,7 @@ function chatRoomLookup(repos, who) {
33
36
  }
34
37
  };
35
38
  }
36
- /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
39
+ /** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
37
40
  function chatMessageStream(domainId, sessionId, room) {
38
41
  return (0, graphql_yoga_1.pipe)(shell_1.pubsub.subscribe(chat_message_broadcast_js_1.CHAT_MESSAGE_TOPIC), (0, graphql_yoga_1.filter)((0, chat_message_broadcast_js_1.chatMessageRoomFilter)(domainId, sessionId, room)));
39
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-room.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-room.ts"],"names":[],"mappings":";;AAqBA,wCAkBC;AAGD,8CAEC;AA5CD;;;;;;;GAOG;AACH,+CAA2C;AAC3C,iDAA8C;AAE9C,2EAA4G;AAM5G;;;GAGG;AACH,SAAgB,cAAc,CAC5B,KAAmD,EACnD,GAA6D;IAE7D,OAAO;QACL,KAAK,CAAC,UAAU;YACd,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAA;YAC5G,OAAO,OAAO,CAAC,CAAC,CAAC,CAAE,OAAe,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QACpE,CAAC;QAED,KAAK,CAAC,aAAa;YACjB,IAAI,CAAC,GAAG,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAA;YAC7B,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC;gBAC5C,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE;aAClG,CAAC,CAAA;YACF,OAAO,CAAC,CAAC,IAAI,CAAA;QACf,CAAC;KACF,CAAA;AACH,CAAC;AAED,gEAAgE;AAChE,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IACzF,OAAO,IAAA,mBAAI,EAAC,cAAM,CAAC,SAAS,CAAC,8CAAkB,CAAC,EAAE,IAAA,qBAAM,EAAC,IAAA,iDAAqB,EAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;AAC7G,CAAC","sourcesContent":["/*\n * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.\n *\n * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을\n * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).\n *\n * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.\n */\nimport { filter, pipe } from 'graphql-yoga'\nimport { pubsub } from '@things-factory/shell'\n\nimport { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'\n\ninterface FindOne {\n findOne(options: { where: any; select?: any }): Promise<any>\n}\n\n/**\n * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와\n * 같은 조건이다.\n */\nexport function chatRoomLookup(\n repos: { sessions: FindOne; participants: FindOne },\n who: { domainId: string; sessionId: string; userId?: string }\n): ChatRoomLookup {\n return {\n async visibility() {\n const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })\n return session ? ((session as any).visibility ?? null) : undefined\n },\n\n async isParticipant() {\n if (!who.userId) return false\n const seat = await repos.participants.findOne({\n where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }\n })\n return !!seat\n }\n }\n}\n\n/** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */\nexport function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {\n return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))\n}\n"]}
1
+ {"version":3,"file":"chat-message-room.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-room.ts"],"names":[],"mappings":";;AAwBA,wCAkBC;AAGD,8CAEC;AA/CD;;;;;;;;;;GAUG;AACH,+CAA2C;AAC3C,iDAA8C;AAE9C,2EAA4G;AAM5G;;;GAGG;AACH,SAAgB,cAAc,CAC5B,KAAmD,EACnD,GAA6D;IAE7D,OAAO;QACL,KAAK,CAAC,UAAU;YACd,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAA;YAC5G,OAAO,OAAO,CAAC,CAAC,CAAC,CAAE,OAAe,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QACpE,CAAC;QAED,KAAK,CAAC,aAAa;YACjB,IAAI,CAAC,GAAG,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAA;YAC7B,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC;gBAC5C,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,EAAE,EAAE;aAClG,CAAC,CAAA;YACF,OAAO,CAAC,CAAC,IAAI,CAAA;QACf,CAAC;KACF,CAAA;AACH,CAAC;AAED,sHAAsH;AACtH,SAAgB,iBAAiB,CAAC,QAAgB,EAAE,SAAiB,EAAE,IAAoB;IACzF,OAAO,IAAA,mBAAI,EAAC,cAAM,CAAC,SAAS,CAAC,8CAAkB,CAAC,EAAE,IAAA,qBAAM,EAAC,IAAA,iDAAqB,EAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;AAC7G,CAAC","sourcesContent":["/*\n * The room on the broadcast path — the stream the subscription returns, and the two lookups that\n * ask about the room.\n *\n * jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the\n * stream lives here — the test runs **the same thing** the resolver returns\n * (`chat-message-room.test.ts`).\n *\n * The caller passes the storage in. Importing the entities here would make this file unreadable to\n * the tests too.\n */\nimport { filter, pipe } from 'graphql-yoga'\nimport { pubsub } from '@things-factory/shell'\n\nimport { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'\n\ninterface FindOne {\n findOne(options: { where: any; select?: any }): Promise<any>\n}\n\n/**\n * The two lookups about a room. **Both search within the caller's domain** — the same condition as\n * the stored path's query (`chatMessages`).\n */\nexport function chatRoomLookup(\n repos: { sessions: FindOne; participants: FindOne },\n who: { domainId: string; sessionId: string; userId?: string }\n): ChatRoomLookup {\n return {\n async visibility() {\n const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })\n return session ? ((session as any).visibility ?? null) : undefined\n },\n\n async isParticipant() {\n if (!who.userId) return false\n const seat = await repos.participants.findOne({\n where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }\n })\n return !!seat\n }\n }\n}\n\n/** The stream the subscription returns — same domain + session, and the room open to this person at every message. */\nexport function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {\n return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))\n}\n"]}
@@ -56,12 +56,13 @@ tslib_1.__decorate([
56
56
  const { sessionId } = args;
57
57
  await (0, shell_1.assertDomainSubscribeAllowed)(domain, user, (d, u) => process.superUserGranted(d, u));
58
58
  /*
59
- * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길
60
- * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나
61
- * 그 대화를 실시간으로 받았다.
59
+ * **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private
60
+ * one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did
61
+ * not, so anyone in the domain holding a private session's id received it live.
62
62
  *
63
- * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는
64
- * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).
63
+ * A room the caller cannot enter is refused rather than handed an empty stream, in the same words
64
+ * as a missing session — so a private room's existence is not confirmed either. Once subscribed,
65
+ * the room is asked again for every message (`chatMessageRoomFilter`).
65
66
  */
66
67
  const room = (0, chat_message_room_js_1.chatRoomLookup)({ sessions: (0, shell_1.getRepository)(chat_session_js_1.ChatSession), participants: (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant) }, { domainId: domain.id, sessionId, userId: user?.id });
67
68
  if (!(await (0, chat_message_broadcast_js_1.mayHearRoom)(room)))
@@ -1 +1 @@
1
- {"version":3,"file":"chat-message-subscription.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-subscription.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,+CAAqE;AACrE,iDAAmF;AACnF,uDAA+C;AAC/C,qEAA6D;AAC7D,yGAAgG;AAChG,2EAAkF;AAClF,iEAA0E;AAC1E,qDAA6C;AAE7C,8EAA8E;AAC9E,qEAA8D;AAArD,6HAAA,kBAAkB,OAAA;AAC3B,yEAAmF;AAA1E,+HAAA,kBAAkB,OAAA;AAAE,8HAAA,iBAAiB,OAAA;AAIvC,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAqC5B,AAJN;;;OAGG;IACH,KAAK,CAAC,mBAAmB,CACf,OAA2B,EACjB,SAAiB,EAC5B,OAAY;QAEnB,MAAM,CAAC,OAAO,CAAC,GAAG,MAAM,IAAA,2BAAU,EAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAA;QAC9D,OAAO,OAAsB,CAAA;IAC/B,CAAC;CACF,CAAA;AA7CY,0DAAuB;AAqC5B;IApCL,IAAA,2BAAY,EAAC,GAAG,EAAE,CAAC,6BAAW,EAAE;QAC/B;;;;;WAKG;QACH,WAAW,EACT,gGAAgG;QAClG,SAAS,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;YACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;YACtC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,CAAA;YAE1B,MAAM,IAAA,oCAA4B,EAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;YAE1F;;;;;;;eAOG;YACH,MAAM,IAAI,GAAG,IAAA,qCAAc,EACzB,EAAE,QAAQ,EAAE,IAAA,qBAAa,EAAC,6BAAW,CAAC,EAAE,YAAY,EAAE,IAAA,qBAAa,EAAC,oDAAsB,CAAC,EAAE,EAC7F,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,CACrD,CAAA;YACD,IAAI,CAAC,CAAC,MAAM,IAAA,uCAAW,EAAC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAA;YAEzE,OAAO,IAAA,wCAAiB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,CAAA;QACtD,CAAC;KACF,CAAC;IACF;;;OAGG;;IAEA,mBAAA,IAAA,mBAAI,GAAE,CAAA;IACN,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;kEAIP;kCA5CU,uBAAuB;IADnC,IAAA,uBAAQ,EAAC,6BAAW,CAAC;GACT,uBAAuB,CA6CnC","sourcesContent":["/*\n * ChatMessageSubscription — 그룹챗 메시지 실시간 방송.\n *\n * 클래스 이름이 `BoardChatMessageSubscription` 이었다. 전선 이름(`chatMessageActivity`)은 처음부터\n * 중립이었고 클래스 이름만 보드였다 — 중립 패키지로 옮기면서 고쳤다(2026-09-10). 전선이 안 바뀌므로\n * 소비처는 그대로다.\n *\n * ChatMessage(사용자 / AI / system) 가 영속될 때마다 같은 세션 구독자에게 방송 →\n * 그룹챗의 척추. 클라이언트(board-ai-chat)가 구독해 원격 메시지를 transcript 에 append.\n * 자기 발신 에코는 클라이언트가 message.id 로 dedup (mutation 응답으로 이미 렌더).\n *\n * patch spine(board-patch-subscription.ts)과 동일 패턴 — PubSub + graphql-ws, domain 격리.\n * 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).\n * 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.\n */\nimport { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'\nimport { assertDomainSubscribeAllowed, getRepository } from '@things-factory/shell'\nimport { ChatMessage } from './chat-message.js'\nimport { ChatSession } from '../chat-session/chat-session.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { mayHearRoom, type ChatMessagePayload } from './chat-message-broadcast.js'\nimport { chatMessageStream, chatRoomLookup } from './chat-message-room.js'\nimport { readerGate } from './reader-gate.js'\n\n// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).\nexport { publishChatMessage } from './chat-message-publish.js'\nexport { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast.js'\nexport type { ChatMessagePayload } from './chat-message-broadcast.js'\n\n@Resolver(ChatMessage)\nexport class ChatMessageSubscription {\n @Subscription(() => ChatMessage, {\n /*\n * 돌려주는 형을 **명시한다**. 이 핸들러가 async 가 되면서(받는 사람마다 가리기 — ADR-0073 판정 1)\n * 반환형이 `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다.\n * 빠지면 스키마를 세울 때 NoExplicitTypeError 로 **패키지 적재 자체가 실패한다** — 시험이 아니라\n * 부팅이 죽는다. plant 레인이 설치본에서 이 오류를 잡아 알려 주었다(2026-09-18).\n */\n description:\n 'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',\n subscribe: async ({ args, context }) => {\n const { domain, user } = context.state\n const { sessionId } = args\n\n await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))\n\n /*\n * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길\n * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나\n * 그 대화를 실시간으로 받았다.\n *\n * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는\n * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).\n */\n const room = chatRoomLookup(\n { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },\n { domainId: domain.id, sessionId, userId: user?.id }\n )\n if (!(await mayHearRoom(room))) throw new Error('chat session not found')\n\n return chatMessageStream(domain.id, sessionId, room)\n }\n })\n /*\n * 방송도 **받는 사람 기준으로** 가린다 — ADR-0073 판정 1. 저장해 놓고 읽을 때만 가리면, 그 자리에\n * 앉아 있던 사람은 방송으로 원문을 받는다. 같은 값이 두 길로 나가고 한쪽만 문을 본다.\n */\n async chatMessageActivity(\n @Root() payload: ChatMessagePayload,\n @Arg('sessionId') sessionId: string,\n @Ctx() context: any\n ): Promise<ChatMessage> {\n const [message] = await readerGate(context)([payload.message])\n return message as ChatMessage\n }\n}\n"]}
1
+ {"version":3,"file":"chat-message-subscription.js","sourceRoot":"","sources":["../../../server/service/chat-message/chat-message-subscription.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;;;;;;;GAcG;AACH,+CAAqE;AACrE,iDAAmF;AACnF,uDAA+C;AAC/C,qEAA6D;AAC7D,yGAAgG;AAChG,2EAAkF;AAClF,iEAA0E;AAC1E,qDAA6C;AAE7C,8EAA8E;AAC9E,qEAA8D;AAArD,6HAAA,kBAAkB,OAAA;AAC3B,yEAAmF;AAA1E,+HAAA,kBAAkB,OAAA;AAAE,8HAAA,iBAAiB,OAAA;AAIvC,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAsC5B,AAJN;;;OAGG;IACH,KAAK,CAAC,mBAAmB,CACf,OAA2B,EACjB,SAAiB,EAC5B,OAAY;QAEnB,MAAM,CAAC,OAAO,CAAC,GAAG,MAAM,IAAA,2BAAU,EAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAA;QAC9D,OAAO,OAAsB,CAAA;IAC/B,CAAC;CACF,CAAA;AA9CY,0DAAuB;AAsC5B;IArCL,IAAA,2BAAY,EAAC,GAAG,EAAE,CAAC,6BAAW,EAAE;QAC/B;;;;;WAKG;QACH,WAAW,EACT,gGAAgG;QAClG,SAAS,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;YACrC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;YACtC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,CAAA;YAE1B,MAAM,IAAA,oCAA4B,EAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;YAE1F;;;;;;;;eAQG;YACH,MAAM,IAAI,GAAG,IAAA,qCAAc,EACzB,EAAE,QAAQ,EAAE,IAAA,qBAAa,EAAC,6BAAW,CAAC,EAAE,YAAY,EAAE,IAAA,qBAAa,EAAC,oDAAsB,CAAC,EAAE,EAC7F,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,CACrD,CAAA;YACD,IAAI,CAAC,CAAC,MAAM,IAAA,uCAAW,EAAC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAA;YAEzE,OAAO,IAAA,wCAAiB,EAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,CAAA;QACtD,CAAC;KACF,CAAC;IACF;;;OAGG;;IAEA,mBAAA,IAAA,mBAAI,GAAE,CAAA;IACN,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;kEAIP;kCA7CU,uBAAuB;IADnC,IAAA,uBAAQ,EAAC,6BAAW,CAAC;GACT,uBAAuB,CA8CnC","sourcesContent":["/*\n * ChatMessageSubscription — 그룹챗 메시지 실시간 방송.\n *\n * 클래스 이름이 `BoardChatMessageSubscription` 이었다. 전선 이름(`chatMessageActivity`)은 처음부터\n * 중립이었고 클래스 이름만 보드였다 — 중립 패키지로 옮기면서 고쳤다(2026-09-10). 전선이 안 바뀌므로\n * 소비처는 그대로다.\n *\n * ChatMessage(사용자 / AI / system) 가 영속될 때마다 같은 세션 구독자에게 방송 →\n * 그룹챗의 척추. 클라이언트(board-ai-chat)가 구독해 원격 메시지를 transcript 에 append.\n * 자기 발신 에코는 클라이언트가 message.id 로 dedup (mutation 응답으로 이미 렌더).\n *\n * patch spine(board-patch-subscription.ts)과 동일 패턴 — PubSub + graphql-ws, domain 격리.\n * 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).\n * 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.\n */\nimport { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'\nimport { assertDomainSubscribeAllowed, getRepository } from '@things-factory/shell'\nimport { ChatMessage } from './chat-message.js'\nimport { ChatSession } from '../chat-session/chat-session.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { mayHearRoom, type ChatMessagePayload } from './chat-message-broadcast.js'\nimport { chatMessageStream, chatRoomLookup } from './chat-message-room.js'\nimport { readerGate } from './reader-gate.js'\n\n// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).\nexport { publishChatMessage } from './chat-message-publish.js'\nexport { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast.js'\nexport type { ChatMessagePayload } from './chat-message-broadcast.js'\n\n@Resolver(ChatMessage)\nexport class ChatMessageSubscription {\n @Subscription(() => ChatMessage, {\n /*\n * 돌려주는 형을 **명시한다**. 이 핸들러가 async 가 되면서(받는 사람마다 가리기 — ADR-0073 판정 1)\n * 반환형이 `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다.\n * 빠지면 스키마를 세울 때 NoExplicitTypeError 로 **패키지 적재 자체가 실패한다** — 시험이 아니라\n * 부팅이 죽는다. plant 레인이 설치본에서 이 오류를 잡아 알려 주었다(2026-09-18).\n */\n description:\n 'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',\n subscribe: async ({ args, context }) => {\n const { domain, user } = context.state\n const { sessionId } = args\n\n await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))\n\n /*\n * **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private\n * one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did\n * not, so anyone in the domain holding a private session's id received it live.\n *\n * A room the caller cannot enter is refused rather than handed an empty stream, in the same words\n * as a missing session — so a private room's existence is not confirmed either. Once subscribed,\n * the room is asked again for every message (`chatMessageRoomFilter`).\n */\n const room = chatRoomLookup(\n { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },\n { domainId: domain.id, sessionId, userId: user?.id }\n )\n if (!(await mayHearRoom(room))) throw new Error('chat session not found')\n\n return chatMessageStream(domain.id, sessionId, room)\n }\n })\n /*\n * 방송도 **받는 사람 기준으로** 가린다 — ADR-0073 판정 1. 저장해 놓고 읽을 때만 가리면, 그 자리에\n * 앉아 있던 사람은 방송으로 원문을 받는다. 같은 값이 두 길로 나가고 한쪽만 문을 본다.\n */\n async chatMessageActivity(\n @Root() payload: ChatMessagePayload,\n @Arg('sessionId') sessionId: string,\n @Ctx() context: any\n ): Promise<ChatMessage> {\n const [message] = await readerGate(context)([payload.message])\n return message as ChatMessage\n }\n}\n"]}
@@ -54,7 +54,7 @@ tslib_1.__decorate([
54
54
  (0, typeorm_1.Column)({ nullable: true }),
55
55
  (0, type_graphql_1.Field)({
56
56
  nullable: true,
57
- description: "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
57
+ description: "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
58
58
  }),
59
59
  tslib_1.__metadata("design:type", String)
60
60
  ], ChatSession.prototype, "anchorType", void 0);
@@ -1 +1 @@
1
- {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /**\n * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).\n *\n * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`\n * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야\n * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로\n * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는\n * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.\n */\n @Column({ type: 'varchar', length: 64, nullable: true })\n @Field({ nullable: true, description: \"Which conversation this session is — the registered mode kind. Written by the server, never sent by the client.\" })\n kind?: string\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
1
+ {"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /**\n * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).\n *\n * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`\n * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야\n * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로\n * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는\n * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.\n */\n @Column({ type: 'varchar', length: 64, nullable: true })\n @Field({ nullable: true, description: \"Which conversation this session is — the registered mode kind. Written by the server, never sent by the client.\" })\n kind?: string\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
@@ -5,9 +5,13 @@
5
5
  * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
6
6
  * instance 트윈 하나에 대한 대화
7
7
  * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
8
+ * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
9
+ * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
10
+ * would put every subject's conversations in one bucket and lose the per-subject list and
11
+ * history boundary (ruled 2026-09-24).
8
12
  * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
9
13
  */
10
- export declare const SESSION_ANCHOR_TYPES: readonly ["board", "figure", "application", "space", "instance", "domain", "none"];
14
+ export declare const SESSION_ANCHOR_TYPES: readonly ["board", "figure", "application", "space", "instance", "domain", "subject", "none"];
11
15
  export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number];
12
16
  export declare function isSessionAnchorType(value: unknown): value is SessionAnchorType;
13
17
  /** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
@@ -55,11 +55,15 @@ exports.anchorWhere = anchorWhere;
55
55
  * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
56
56
  * instance 트윈 하나에 대한 대화
57
57
  * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
58
+ * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
59
+ * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
60
+ * would put every subject's conversations in one bucket and lose the per-subject list and
61
+ * history boundary (ruled 2026-09-24).
58
62
  * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
59
63
  */
60
64
  // Figure-specific history requested explicitly: reuse the existing varchar
61
65
  // anchor columns, without writing Figure ids into the legacy boardId column.
62
- exports.SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'];
66
+ exports.SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'];
63
67
  function isSessionAnchorType(value) {
64
68
  return typeof value === 'string' && exports.SESSION_ANCHOR_TYPES.includes(value);
65
69
  }
@@ -74,6 +78,22 @@ function assertAnchorType(anchorType, where) {
74
78
  }
75
79
  return type;
76
80
  }
81
+ /**
82
+ * A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).
83
+ *
84
+ * Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like
85
+ * `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes
86
+ * and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on
87
+ * read, rather than stored and never found.
88
+ */
89
+ const SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
90
+ function assertSubjectAnchorId(type, id, where) {
91
+ if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id))
92
+ return;
93
+ throw new Error(`[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +
94
+ "A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, " +
95
+ "for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first.");
96
+ }
77
97
  /** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
78
98
  function anchorColumns(anchorType, anchorId) {
79
99
  const type = assertAnchorType(anchorType, 'anchorColumns');
@@ -84,6 +104,7 @@ function anchorColumns(anchorType, anchorId) {
84
104
  'given — the session would not be found by its own anchor and every call would create another. ' +
85
105
  "Use 'none' for a conversation that is deliberately not addressable by anchor.");
86
106
  }
107
+ assertSubjectAnchorId(type, id, 'anchorColumns');
87
108
  return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined };
88
109
  }
89
110
  /**
@@ -104,6 +125,7 @@ function anchorColumns(anchorType, anchorId) {
104
125
  */
105
126
  function anchorWhere(domainId, anchorType, anchorId, kind) {
106
127
  const type = assertAnchorType(anchorType, 'anchorWhere');
128
+ assertSubjectAnchorId(type, anchorId, 'anchorWhere');
107
129
  const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } };
108
130
  return kind ? { ...where, kind } : where;
109
131
  }
@@ -1 +1 @@
1
- {"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;AAiBH,kDAEC;AAiBD,sCAeC;AAkBD,kCAIC;AAvED;;;;;;;;GAQG;AACH,2EAA2E;AAC3E,6EAA6E;AAChE,QAAA,oBAAoB,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAU,CAAA;AAItH,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,4BAA0C,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACjG,CAAC;AAED,SAAS,gBAAgB,CAAC,UAA8B,EAAE,KAAa;IACrE,MAAM,IAAI,GAAG,UAAU,IAAI,MAAM,CAAA;IACjC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,6BAA6B,KAAK,MAAM,IAAI,2BAA2B;YACrE,YAAY,4BAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI;YAChD,gGAAgG;YAChG,2EAA2E;YAC3E,4EAA4E,CAC/E,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,oEAAoE;AACpE,SAAgB,aAAa,CAC3B,UAA8B,EAC9B,QAA4B;IAE5B,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,QAAQ,IAAI,SAAS,CAAA;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,oDAAoD,IAAI,qBAAqB;YAC3E,+FAA+F;YAC/F,gGAAgG;YAChG,+EAA+E,CAClF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;AACvF,CAAC;AAED;;;;;;GAMG;AACH;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,QAAgB,EAAE,UAAkB,EAAE,QAAgB,EAAE,IAAa;IAC/F,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;IACxD,MAAM,KAAK,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;IAC3I,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;AAC1C,CAAC","sourcesContent":["/*\n * 세션 앵커 순수 로직 — 리졸버와 분리.\n *\n * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel\n * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·\n * chat-input-builder 와 같은 관례).\n *\n * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서\n * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영\n * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.\n *\n * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board\n * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께\n * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.\n *\n * ── Why this file validates instead of casting (2026-09-09) ──────────────────────\n * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a\n * check: the type existed and nothing enforced it, so any string reached a nullable varchar and\n * was stored. The plant lane was about to add a product and stopped to ask which value to write,\n * which is the only reason this was noticed — the column would have accepted whatever it sent.\n *\n * A wrong value here is not a wrong value in one row. The anchor decides which conversations are\n * listed together and which history a reply may see, so a typo silently creates a second,\n * invisible bucket: the session is written, the inbox query for the real anchor does not match\n * it, and the conversation is simply gone from the list with no error anywhere.\n *\n * Two rules follow, and both are refusals rather than defaults:\n *\n * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it\n * turns \"you named something that does not exist\" into \"this conversation is about nothing\".\n * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points\n * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored\n * NULL — the lookup misses its own row and every call creates another session.\n *\n * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for\n * \"nothing picked yet\" (twin's dock refuses to create a session while it holds 'none'), not a\n * value a product writes because it has nothing narrower to point at. A product whose subject is\n * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and\n * has a history boundary, and \"unanchored\" has neither.\n *\n * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new\n * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it\n * orphans them.\n */\n\n/**\n * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**\n *\n * board 보드 저작 대화 — 그 보드의 편집 이력\n * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들\n * instance 트윈 하나에 대한 대화\n * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때\n * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다\n */\n// Figure-specific history requested explicitly: reuse the existing varchar\n// anchor columns, without writing Figure ids into the legacy boardId column.\nexport const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const\n\nexport type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]\n\nexport function isSessionAnchorType(value: unknown): value is SessionAnchorType {\n return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)\n}\n\nfunction assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {\n const type = anchorType || 'none'\n if (!isSessionAnchorType(type)) {\n throw new Error(\n `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +\n `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +\n 'The anchor decides which conversations are listed together and which history a reply may see, ' +\n 'so an unrecognised value would write a session the inbox can never find. ' +\n 'If a new kind of subject is needed, raise it — this set is stored in rows.'\n )\n }\n return type\n}\n\n/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */\nexport function anchorColumns(\n anchorType: string | undefined,\n anchorId: string | undefined\n): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {\n const type = assertAnchorType(anchorType, 'anchorColumns')\n const id = anchorId || undefined\n if (type !== 'none' && !id) {\n throw new Error(\n `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +\n 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +\n 'given — the session would not be found by its own anchor and every call would create another. ' +\n \"Use 'none' for a conversation that is deliberately not addressable by anchor.\"\n )\n }\n return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }\n}\n\n/**\n * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다\n * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.\n *\n * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,\n * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.\n */\n/**\n * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).\n *\n * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영\n * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이\n * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.\n */\nexport function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {\n const type = assertAnchorType(anchorType, 'anchorWhere')\n const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }\n return kind ? { ...where, kind } : where\n}\n"]}
1
+ {"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;AAqBH,kDAEC;AAoCD,sCAgBC;AAkBD,kCAKC;AAhGD;;;;;;;;;;;;GAYG;AACH,2EAA2E;AAC3E,6EAA6E;AAChE,QAAA,oBAAoB,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAU,CAAA;AAIjI,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,4BAA0C,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACjG,CAAC;AAED,SAAS,gBAAgB,CAAC,UAA8B,EAAE,KAAa;IACrE,MAAM,IAAI,GAAG,UAAU,IAAI,MAAM,CAAA;IACjC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,6BAA6B,KAAK,MAAM,IAAI,2BAA2B;YACrE,YAAY,4BAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI;YAChD,gGAAgG;YAChG,2EAA2E;YAC3E,4EAA4E,CAC/E,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,iBAAiB,GAAG,iFAAiF,CAAA;AAE3G,SAAS,qBAAqB,CAAC,IAAuB,EAAE,EAAsB,EAAE,KAAa;IAC3F,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,EAAE,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;QAAE,OAAM;IACnE,MAAM,IAAI,KAAK,CACb,iCAAiC,KAAK,MAAM,EAAE,6BAA6B;QACzE,wFAAwF;QACxF,oGAAoG,CACvG,CAAA;AACH,CAAC;AAED,oEAAoE;AACpE,SAAgB,aAAa,CAC3B,UAA8B,EAC9B,QAA4B;IAE5B,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,QAAQ,IAAI,SAAS,CAAA;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,oDAAoD,IAAI,qBAAqB;YAC3E,+FAA+F;YAC/F,gGAAgG;YAChG,+EAA+E,CAClF,CAAA;IACH,CAAC;IACD,qBAAqB,CAAC,IAAI,EAAE,EAAE,EAAE,eAAe,CAAC,CAAA;IAChD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;AACvF,CAAC;AAED;;;;;;GAMG;AACH;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,QAAgB,EAAE,UAAkB,EAAE,QAAgB,EAAE,IAAa;IAC/F,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;IACxD,qBAAqB,CAAC,IAAI,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAA;IACpD,MAAM,KAAK,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;IAC3I,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;AAC1C,CAAC","sourcesContent":["/*\n * 세션 앵커 순수 로직 — 리졸버와 분리.\n *\n * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel\n * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·\n * chat-input-builder 와 같은 관례).\n *\n * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서\n * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영\n * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.\n *\n * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board\n * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께\n * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.\n *\n * ── Why this file validates instead of casting (2026-09-09) ──────────────────────\n * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a\n * check: the type existed and nothing enforced it, so any string reached a nullable varchar and\n * was stored. The plant lane was about to add a product and stopped to ask which value to write,\n * which is the only reason this was noticed — the column would have accepted whatever it sent.\n *\n * A wrong value here is not a wrong value in one row. The anchor decides which conversations are\n * listed together and which history a reply may see, so a typo silently creates a second,\n * invisible bucket: the session is written, the inbox query for the real anchor does not match\n * it, and the conversation is simply gone from the list with no error anywhere.\n *\n * Two rules follow, and both are refusals rather than defaults:\n *\n * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it\n * turns \"you named something that does not exist\" into \"this conversation is about nothing\".\n * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points\n * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored\n * NULL — the lookup misses its own row and every call creates another session.\n *\n * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for\n * \"nothing picked yet\" (twin's dock refuses to create a session while it holds 'none'), not a\n * value a product writes because it has nothing narrower to point at. A product whose subject is\n * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and\n * has a history boundary, and \"unanchored\" has neither.\n *\n * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new\n * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it\n * orphans them.\n */\n\n/**\n * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**\n *\n * board 보드 저작 대화 — 그 보드의 편집 이력\n * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들\n * instance 트윈 하나에 대한 대화\n * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때\n * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared\n * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead\n * would put every subject's conversations in one bucket and lose the per-subject list and\n * history boundary (ruled 2026-09-24).\n * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다\n */\n// Figure-specific history requested explicitly: reuse the existing varchar\n// anchor columns, without writing Figure ids into the legacy boardId column.\nexport const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'] as const\n\nexport type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]\n\nexport function isSessionAnchorType(value: unknown): value is SessionAnchorType {\n return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)\n}\n\nfunction assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {\n const type = anchorType || 'none'\n if (!isSessionAnchorType(type)) {\n throw new Error(\n `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +\n `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +\n 'The anchor decides which conversations are listed together and which history a reply may see, ' +\n 'so an unrecognised value would write a session the inbox can never find. ' +\n 'If a new kind of subject is needed, raise it — this set is stored in rows.'\n )\n }\n return type\n}\n\n/**\n * A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).\n *\n * Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like\n * `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes\n * and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on\n * read, rather than stored and never found.\n */\nconst SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction assertSubjectAnchorId(type: SessionAnchorType, id: string | undefined, where: string): void {\n if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id)) return\n throw new Error(\n `[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +\n \"A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, \" +\n \"for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first.\"\n )\n}\n\n/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */\nexport function anchorColumns(\n anchorType: string | undefined,\n anchorId: string | undefined\n): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {\n const type = assertAnchorType(anchorType, 'anchorColumns')\n const id = anchorId || undefined\n if (type !== 'none' && !id) {\n throw new Error(\n `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +\n 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +\n 'given — the session would not be found by its own anchor and every call would create another. ' +\n \"Use 'none' for a conversation that is deliberately not addressable by anchor.\"\n )\n }\n assertSubjectAnchorId(type, id, 'anchorColumns')\n return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }\n}\n\n/**\n * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다\n * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.\n *\n * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,\n * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.\n */\n/**\n * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).\n *\n * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영\n * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이\n * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.\n */\nexport function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {\n const type = assertAnchorType(anchorType, 'anchorWhere')\n assertSubjectAnchorId(type, anchorId, 'anchorWhere')\n const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }\n return kind ? { ...where, kind } : where\n}\n"]}
@@ -44,11 +44,12 @@ async function runRegisteredMode(kind, options = {}) {
44
44
  maxTokens: settings.maxTokens,
45
45
  hostContext: options.hostContext,
46
46
  /*
47
- * 이 하네스가 묻는 것은 **모드**다 — 「이 모드가 무엇을 여는가」이지 「이 사람이 문을 지나는가」가
48
- * 아니다. 관문이 `doors` 로 옮겨간 뒤(ADR-0071 결정 2) 검사를 안 넘기면 문을 적은 도구가 전부
49
- * 닫히고, 그러면 앱의 적합성 시험에서 **실제 자료를 읽는 도구만** 조용히 사라진다. 그래서 기본은
50
- * 모든 문을 지나는 사람이고, 다른 쪽을 보고 싶은 앱은 `toolAccess` 를 적어 그 경우를 쓴다.
51
- * 권한 축은 자기 시험이 따로 본다(`tool-access.test.ts` · `tool-scope.test.ts`).
47
+ * This harness asks about **the mode** — "what does this mode open", not "does this person pass the
48
+ * doors". Since the gate moved onto `doors` (ADR-0071 decision 2), passing no check closes every tool
49
+ * that names a door, and an app's conformance test would quietly lose **exactly the tools that read
50
+ * real data**. So the default is someone who passes every door; an app that wants the other case
51
+ * writes `toolAccess`. The permission axis has its own tests (`tool-access.test.ts` ·
52
+ * `tool-scope.test.ts`).
52
53
  */
53
54
  toolAccess: options.toolAccess ?? (async () => true),
54
55
  state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }