@things-factory/ai-assistant 10.1.47 → 10.1.52
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/client/components/assistant-chat.ts +97 -19
- package/client/components/assistant-dock.ts +617 -0
- package/client/components/assistant-mode.ts +2 -0
- package/client/components/assistant-session-manager.ts +607 -0
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/chat-input-builder.ts +4 -1
- package/client/components/panel-aria.test.ts +13 -8
- package/client/index.ts +8 -0
- package/client/setup-assistant-dock.ts +118 -0
- package/client/utils/assistant-activity.ts +122 -0
- package/client/utils/assistant-ask.ts +44 -0
- package/client/utils/assistant-proposals.ts +50 -0
- package/client/utils/assistant-session-actions.ts +18 -0
- package/client/utils/assistant-session-admin.ts +167 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +6 -2
- package/client/utils/assistant-surface.ts +157 -0
- package/client/utils/attachment-policy.ts +71 -0
- package/dist-client/components/assistant-chat.d.ts +6 -0
- package/dist-client/components/assistant-chat.js +98 -20
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +74 -0
- package/dist-client/components/assistant-dock.js +641 -0
- package/dist-client/components/assistant-dock.js.map +1 -0
- package/dist-client/components/assistant-mode.d.ts +6 -0
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +77 -0
- package/dist-client/components/assistant-session-manager.js +652 -0
- package/dist-client/components/assistant-session-manager.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.js +4 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +2 -0
- package/dist-client/components/chat-input-builder.js +2 -1
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/panel-aria.test.js +12 -8
- package/dist-client/components/panel-aria.test.js.map +1 -1
- package/dist-client/index.d.ts +8 -0
- package/dist-client/index.js +8 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/setup-assistant-dock.d.ts +15 -0
- package/dist-client/setup-assistant-dock.js +119 -0
- package/dist-client/setup-assistant-dock.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-activity.d.ts +22 -0
- package/dist-client/utils/assistant-activity.js +104 -0
- package/dist-client/utils/assistant-activity.js.map +1 -0
- package/dist-client/utils/assistant-ask.d.ts +23 -0
- package/dist-client/utils/assistant-ask.js +18 -0
- package/dist-client/utils/assistant-ask.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +29 -0
- package/dist-client/utils/assistant-proposals.js +22 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -0
- package/dist-client/utils/assistant-session-actions.d.ts +12 -0
- package/dist-client/utils/assistant-session-actions.js +19 -0
- package/dist-client/utils/assistant-session-actions.js.map +1 -0
- package/dist-client/utils/assistant-session-admin.d.ts +72 -0
- package/dist-client/utils/assistant-session-admin.js +81 -0
- package/dist-client/utils/assistant-session-admin.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +6 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/assistant-surface.d.ts +72 -0
- package/dist-client/utils/assistant-surface.js +99 -0
- package/dist-client/utils/assistant-surface.js.map +1 -0
- package/dist-client/utils/attachment-policy.d.ts +35 -0
- package/dist-client/utils/attachment-policy.js +56 -0
- package/dist-client/utils/attachment-policy.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +71 -3
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
- package/dist-server/service/assistant-mode/resolve.js +6 -2
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +6 -0
- package/dist-server/service/assistant-mode/types.js +0 -9
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message.js +19 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
- package/dist-server/service/chat-session/attachment-policy.js +56 -0
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
- package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-route.js +44 -0
- package/dist-server/service/chat-session/attachment-route.js.map +1 -0
- package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-tools.js +59 -0
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
- package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
- package/dist-server/service/chat-session/session-attachments.js +84 -0
- package/dist-server/service/chat-session/session-attachments.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
- package/dist-server/service/chat-session/session-inbox.js +32 -8
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-purge.d.ts +14 -0
- package/dist-server/service/chat-session/session-purge.js +77 -0
- package/dist-server/service/chat-session/session-purge.js.map +1 -0
- package/dist-server/service/chat-session/session-retention.d.ts +6 -0
- package/dist-server/service/chat-session/session-retention.js +20 -0
- package/dist-server/service/chat-session/session-retention.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
- package/dist-server/service/chat-session/session-visibility.js +11 -0
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
- package/dist-server/service/chat-session/session-write-gate.js +24 -0
- package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
- package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
- package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
- package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +17 -2
- package/dist-server/service/chat-session-resolver.js +135 -15
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -0
- package/dist-server/service/index.js +5 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -2
- package/server/service/assistant-chat-resolver.ts +71 -4
- package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
- package/server/service/assistant-mode/resolve.ts +9 -2
- package/server/service/assistant-mode/types.ts +6 -0
- package/server/service/chat-message/chat-message.ts +17 -0
- package/server/service/chat-session/attachment-policy.test.ts +42 -0
- package/server/service/chat-session/attachment-policy.ts +68 -0
- package/server/service/chat-session/attachment-route.ts +45 -0
- package/server/service/chat-session/attachment-tools.ts +63 -0
- package/server/service/chat-session/session-attachments.ts +89 -0
- package/server/service/chat-session/session-inbox.test.ts +71 -1
- package/server/service/chat-session/session-inbox.ts +61 -11
- package/server/service/chat-session/session-purge.test.ts +24 -0
- package/server/service/chat-session/session-purge.ts +81 -0
- package/server/service/chat-session/session-retention.ts +17 -0
- package/server/service/chat-session/session-visibility.ts +11 -0
- package/server/service/chat-session/session-write-gate.test.ts +53 -0
- package/server/service/chat-session/session-write-gate.ts +31 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
- package/server/service/chat-session-participant/invite-notification.ts +24 -6
- package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
- package/server/service/chat-session-participant/participant-lookup.ts +22 -0
- package/server/service/chat-session-resolver.ts +130 -13
- package/server/service/index.ts +5 -0
- package/test/assistant-dock.test.ts +124 -0
- package/test/assistant-session-manager.test.ts +126 -0
- package/test/attachment-policy-agree.test.ts +32 -0
- package/test/open-doors.test.ts +5 -2
- package/translations/en.json +61 -1
- package/translations/ja.json +61 -1
- package/translations/ko.json +61 -1
- package/translations/ms.json +61 -1
- package/translations/zh.json +61 -1
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { mayReadSession } from './session-visibility.js'
|
|
19
|
+
|
|
18
20
|
/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */
|
|
19
21
|
export interface InboxSessionRow {
|
|
20
22
|
id?: string
|
|
@@ -29,11 +31,15 @@ export interface InboxSessionRow {
|
|
|
29
31
|
/** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */
|
|
30
32
|
lastMessageDoors?: string[] | null
|
|
31
33
|
createdAt?: Date | string | null
|
|
34
|
+
/** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */
|
|
35
|
+
visibility?: string | null
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** 내 참여 기록(읽은 지점). */
|
|
35
39
|
export interface MyParticipation {
|
|
36
40
|
sessionId?: string
|
|
41
|
+
/** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */
|
|
42
|
+
role?: string | null
|
|
37
43
|
lastReadAt?: Date | string | null
|
|
38
44
|
/** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */
|
|
39
45
|
hiddenAt?: Date | string | null
|
|
@@ -46,6 +52,10 @@ export interface InboxEntry extends InboxSessionRow {
|
|
|
46
52
|
participant: boolean
|
|
47
53
|
/** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */
|
|
48
54
|
hidden?: boolean
|
|
55
|
+
/** `open` · `private` — 목록이 자물쇠를 그리는 근거. */
|
|
56
|
+
visibility: string
|
|
57
|
+
/** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */
|
|
58
|
+
owner: boolean
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
function time(v: Date | string | null | undefined): number {
|
|
@@ -68,23 +78,33 @@ function time(v: Date | string | null | undefined): number {
|
|
|
68
78
|
export function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {
|
|
69
79
|
const readBy = new Map<string, number>()
|
|
70
80
|
const isParticipant = new Set<string>()
|
|
81
|
+
const owns = new Set<string>()
|
|
71
82
|
for (const p of mine) {
|
|
72
83
|
if (!p.sessionId) continue
|
|
73
84
|
isParticipant.add(p.sessionId)
|
|
74
85
|
readBy.set(p.sessionId, time(p.lastReadAt))
|
|
86
|
+
if (p.role === 'owner') owns.add(p.sessionId)
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
const entries = sessions
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
const entries = sessions
|
|
90
|
+
/*
|
|
91
|
+
* 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.
|
|
92
|
+
* 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.
|
|
93
|
+
*/
|
|
94
|
+
.filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))
|
|
95
|
+
.map(session => {
|
|
96
|
+
const id = session.id ?? ''
|
|
97
|
+
const activity = time(session.lastMessageAt)
|
|
98
|
+
const participant = isParticipant.has(id)
|
|
99
|
+
const read = readBy.get(id) ?? 0
|
|
100
|
+
return {
|
|
101
|
+
...session,
|
|
102
|
+
participant,
|
|
103
|
+
visibility: session.visibility ?? 'open',
|
|
104
|
+
owner: owns.has(id),
|
|
105
|
+
unread: participant && activity > 0 && activity > read
|
|
106
|
+
}
|
|
107
|
+
})
|
|
88
108
|
|
|
89
109
|
return entries.sort(
|
|
90
110
|
(a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))
|
|
@@ -121,6 +141,36 @@ export function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date |
|
|
|
121
141
|
* **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
|
|
122
142
|
* 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
|
|
123
143
|
*/
|
|
144
|
+
/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */
|
|
145
|
+
export interface InboxFilter {
|
|
146
|
+
/** Only this conversation kind (mode). Absent: every kind on the anchor. */
|
|
147
|
+
kind?: string | null
|
|
148
|
+
/** Only the ones I started. */
|
|
149
|
+
mineOnly?: boolean | null
|
|
150
|
+
/** Only the ones I am in (owner or member). */
|
|
151
|
+
participantOnly?: boolean | null
|
|
152
|
+
/** Only 'open' or only 'private'. */
|
|
153
|
+
visibility?: string | null
|
|
154
|
+
/** Only the ones with activity I have not read. */
|
|
155
|
+
unreadOnly?: boolean | null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and
|
|
160
|
+
* this decides only what the person asked to see. A session from before the kind column has no kind:
|
|
161
|
+
* it is shown under every kind, so it is never lost from the list it was started in.
|
|
162
|
+
*/
|
|
163
|
+
export function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {
|
|
164
|
+
return entries.filter(entry => {
|
|
165
|
+
if (filter.kind && entry.kind && entry.kind !== filter.kind) return false
|
|
166
|
+
if (filter.mineOnly && !entry.mine) return false
|
|
167
|
+
if (filter.participantOnly && !entry.participant) return false
|
|
168
|
+
if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false
|
|
169
|
+
if (filter.unreadOnly && !entry.unread) return false
|
|
170
|
+
return true
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
124
174
|
export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {
|
|
125
175
|
const q = (search ?? '').trim().toLowerCase()
|
|
126
176
|
if (!q) return entries
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* When a deleted conversation is emptied - design/ai/shared-dock.md §9-5. */
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention'
|
|
4
|
+
|
|
5
|
+
describe('retention', () => {
|
|
6
|
+
it('defaults to 30 days, and reads a positive number of days', () => {
|
|
7
|
+
expect(DEFAULT_RETENTION_DAYS).toBe(30)
|
|
8
|
+
expect(retentionDaysOf(undefined)).toBe(30)
|
|
9
|
+
expect(retentionDaysOf('x')).toBe(30)
|
|
10
|
+
expect(retentionDaysOf(7)).toBe(7)
|
|
11
|
+
expect(retentionDaysOf('14')).toBe(14)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('0 or false turns the job off', () => {
|
|
15
|
+
expect(retentionDaysOf(0)).toBeNull()
|
|
16
|
+
expect(retentionDaysOf(false)).toBeNull()
|
|
17
|
+
expect(retentionDaysOf('0')).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('a conversation is due once its deletion is older than the retention', () => {
|
|
21
|
+
const now = new Date('2026-09-25T00:00:00Z')
|
|
22
|
+
expect(purgeCutoff(now, 30).toISOString()).toBe('2026-08-26T00:00:00.000Z')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.
|
|
3
|
+
*
|
|
4
|
+
* `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files
|
|
5
|
+
* (every door checks the session), and within the retention period it can still be brought back whole.
|
|
6
|
+
* Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed
|
|
7
|
+
* forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached
|
|
8
|
+
* files together once the retention period has passed.
|
|
9
|
+
*
|
|
10
|
+
* Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.
|
|
11
|
+
*/
|
|
12
|
+
import { config } from '@things-factory/env'
|
|
13
|
+
import { getRepository } from '@things-factory/shell'
|
|
14
|
+
import { In, IsNull, LessThan, Not } from 'typeorm'
|
|
15
|
+
|
|
16
|
+
import { ChatMessage } from '../chat-message/chat-message.js'
|
|
17
|
+
import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
|
|
18
|
+
import { ChatSession } from './chat-session.js'
|
|
19
|
+
import { removeSessionFiles } from './session-attachments.js'
|
|
20
|
+
|
|
21
|
+
import { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'
|
|
22
|
+
|
|
23
|
+
export { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'
|
|
24
|
+
/** How many conversations one run empties - a large backlog is worked off over several runs. */
|
|
25
|
+
export const PURGE_BATCH = 100
|
|
26
|
+
|
|
27
|
+
export interface PurgeResult {
|
|
28
|
+
purged: number
|
|
29
|
+
failed: { sessionId: string; message: string }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Empties the conversations whose deletion is older than the retention period. */
|
|
33
|
+
export async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {
|
|
34
|
+
const result: PurgeResult = { purged: 0, failed: [] }
|
|
35
|
+
if (retentionDays === null) return result
|
|
36
|
+
|
|
37
|
+
const sessions = await getRepository(ChatSession).find({
|
|
38
|
+
where: { deletedAt: LessThan(purgeCutoff(now, retentionDays)) } as any,
|
|
39
|
+
withDeleted: true,
|
|
40
|
+
relations: ['domain'],
|
|
41
|
+
take: PURGE_BATCH
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
for (const session of sessions) {
|
|
45
|
+
try {
|
|
46
|
+
await removeSessionFiles(session as any)
|
|
47
|
+
/* Messages point at each other (parentMessage) - drop the links before the rows. */
|
|
48
|
+
await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)
|
|
49
|
+
await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)
|
|
50
|
+
await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)
|
|
51
|
+
/* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
|
|
52
|
+
await getRepository(ChatSession).delete({ id: In([session.id]) } as any)
|
|
53
|
+
result.purged++
|
|
54
|
+
} catch (error) {
|
|
55
|
+
/* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */
|
|
56
|
+
result.failed.push({ sessionId: session.id!, message: (error as Error).message })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
64
|
+
|
|
65
|
+
/** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */
|
|
66
|
+
export function schedulePurge(): void {
|
|
67
|
+
if (timer) return
|
|
68
|
+
const run = () =>
|
|
69
|
+
purgeDeletedSessions()
|
|
70
|
+
.then(({ purged, failed }) => {
|
|
71
|
+
if (purged || failed.length) console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '')
|
|
72
|
+
})
|
|
73
|
+
.catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))
|
|
74
|
+
|
|
75
|
+
const first = setTimeout(run, 60 * 1000)
|
|
76
|
+
first.unref?.()
|
|
77
|
+
timer = setInterval(run, DAY_MS)
|
|
78
|
+
timer.unref?.()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.on('bootstrap-module-start' as any, () => schedulePurge())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* How long a deleted conversation is kept before it is emptied - pure, tested alone (shared-dock.md §9-5). */
|
|
2
|
+
|
|
3
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
4
|
+
export const DEFAULT_RETENTION_DAYS = 30
|
|
5
|
+
|
|
6
|
+
/** The retention the config asks for: a positive number of days, `null` when the job is off, the default when unreadable. */
|
|
7
|
+
export function retentionDaysOf(value: unknown): number | null {
|
|
8
|
+
if (value === false || value === 0 || value === '0') return null
|
|
9
|
+
const days = Number(value)
|
|
10
|
+
return Number.isFinite(days) && days > 0 ? days : DEFAULT_RETENTION_DAYS
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Conversations deleted before this moment are due. */
|
|
14
|
+
export function purgeCutoff(now: Date, retentionDays: number): Date {
|
|
15
|
+
return new Date(now.getTime() - retentionDays * DAY_MS)
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -32,3 +32,14 @@ export function initialVisibility(session: { anchorType?: string | null; copyOf?
|
|
|
32
32
|
export function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
|
|
33
33
|
return (visibility ?? 'open') === 'open' || isParticipant
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.
|
|
38
|
+
*
|
|
39
|
+
* 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을
|
|
40
|
+
* 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는
|
|
41
|
+
* 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.
|
|
42
|
+
*/
|
|
43
|
+
export function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
|
|
44
|
+
return mayReadSession(visibility, isParticipant)
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The write gate - a private conversation is written by its people only (ADR-0073 decision 1, for writing).
|
|
3
|
+
* Reproduces operato-ecs two-person test step 7 (2026-09-24): an outsider wrote into a private conversation
|
|
4
|
+
* by its id, and through assistantChat the answer would be built on that private history.
|
|
5
|
+
*/
|
|
6
|
+
import { assertMayWriteSession } from './session-write-gate'
|
|
7
|
+
import { mayWriteSession } from './session-visibility'
|
|
8
|
+
import { isRoomRole } from '../chat-session-participant/participant-lookup-roles'
|
|
9
|
+
|
|
10
|
+
const t = (_key: string, options?: any) => options?.defaultValue ?? _key
|
|
11
|
+
|
|
12
|
+
describe('who may write into a conversation', () => {
|
|
13
|
+
it('anyone in the domain writes into a public one - no lookup', async () => {
|
|
14
|
+
let asked = false
|
|
15
|
+
await expect(
|
|
16
|
+
assertMayWriteSession({ session: { visibility: 'open' }, userId: 'bob', isParticipant: async () => ((asked = true), false), t })
|
|
17
|
+
).resolves.toBeUndefined()
|
|
18
|
+
expect(asked).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('a row from before the visibility column is public', async () => {
|
|
22
|
+
await expect(assertMayWriteSession({ session: {}, userId: 'bob', isParticipant: async () => false, t })).resolves.toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('★ an outsider is refused on a private one, by a sentence that says what to do', async () => {
|
|
26
|
+
await expect(
|
|
27
|
+
assertMayWriteSession({ session: { visibility: 'private' }, userId: 'bob', isParticipant: async () => false, t })
|
|
28
|
+
).rejects.toThrow(/private - only the people in it can write here/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('a participant writes into a private one', async () => {
|
|
32
|
+
await expect(
|
|
33
|
+
assertMayWriteSession({ session: { visibility: 'private' }, userId: 'alice', isParticipant: async () => true, t })
|
|
34
|
+
).resolves.toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('no user is no participant', async () => {
|
|
38
|
+
await expect(assertMayWriteSession({ session: { visibility: 'private' }, isParticipant: async () => true, t })).rejects.toThrow()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('the write rule is the read rule', () => {
|
|
42
|
+
expect(mayWriteSession('open', false)).toBe(true)
|
|
43
|
+
expect(mayWriteSession('private', false)).toBe(false)
|
|
44
|
+
expect(mayWriteSession('private', true)).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('★ a hide-only viewer row is not being in the room', () => {
|
|
48
|
+
expect(isRoomRole('owner')).toBe(true)
|
|
49
|
+
expect(isRoomRole('member')).toBe(true)
|
|
50
|
+
expect(isRoomRole('viewer')).toBe(false)
|
|
51
|
+
expect(isRoomRole(undefined)).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The gate every door that writes into a conversation passes - ADR-0073 decision 1, applied to writing.
|
|
3
|
+
*
|
|
4
|
+
* `mayWriteSession` is the rule; this looks up only what the rule needs (whether the writer is in the
|
|
5
|
+
* room, and only for a private conversation) and refuses by a named message. Kept apart from the resolvers
|
|
6
|
+
* so the five write doors call one line and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
import { mayWriteSession } from './session-visibility.js'
|
|
9
|
+
|
|
10
|
+
export interface WriteGateInput {
|
|
11
|
+
session: { id?: string; visibility?: string | null }
|
|
12
|
+
userId?: string
|
|
13
|
+
/** Is this user a participant of the session (owner or member)? Asked only for a private one. */
|
|
14
|
+
isParticipant: () => Promise<boolean>
|
|
15
|
+
t: (key: string, options?: Record<string, unknown>) => string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function assertMayWriteSession({ session, userId, isParticipant, t }: WriteGateInput): Promise<void> {
|
|
19
|
+
const visibility = session.visibility ?? 'open'
|
|
20
|
+
/* A public conversation is open to everyone in the domain - no lookup. */
|
|
21
|
+
if (visibility === 'open') return
|
|
22
|
+
|
|
23
|
+
const participant = !!userId && (await isParticipant())
|
|
24
|
+
if (!mayWriteSession(visibility, participant)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
t('ai-assistant.text.private-session-refused', {
|
|
27
|
+
defaultValue: 'this conversation is private - only the people in it can write here. ask one of them to bring you in.'
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -28,3 +28,18 @@ describe('buildInviteNotification — 협업 초대 알림 payload', () => {
|
|
|
28
28
|
expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' })!.url).toBeUndefined()
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
describe('an invite outside a board', () => {
|
|
33
|
+
it('does not call it a board', () => {
|
|
34
|
+
const p = buildInviteNotification(['u1'], undefined, { name: 'Kim' }, 'press-plc down')!
|
|
35
|
+
expect(p.type).toBe('chat-invite')
|
|
36
|
+
expect(p.title).toBe('Conversation invite')
|
|
37
|
+
expect(p.title).not.toMatch(/board/i)
|
|
38
|
+
expect(p.body).toContain('press-plc down')
|
|
39
|
+
expect(p.url).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('says a conversation when it has no name yet', () => {
|
|
43
|
+
expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' })!.body).toBe('Kim brought you into a conversation.')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -30,19 +30,37 @@ export interface Inviter {
|
|
|
30
30
|
export function buildInviteNotification(
|
|
31
31
|
userIds: string[],
|
|
32
32
|
boardId: string | undefined,
|
|
33
|
-
inviter: Inviter | undefined | null
|
|
33
|
+
inviter: Inviter | undefined | null,
|
|
34
|
+
sessionName?: string | null
|
|
34
35
|
): InviteNotification | null {
|
|
35
36
|
if (!Array.isArray(userIds) || userIds.length === 0) return null
|
|
36
37
|
|
|
37
38
|
const who = inviter?.name || inviter?.email || 'someone'
|
|
39
|
+
|
|
40
|
+
/* A board conversation keeps its board wording and the link to the modeller. */
|
|
41
|
+
if (boardId) {
|
|
42
|
+
return {
|
|
43
|
+
receivers: userIds,
|
|
44
|
+
type: 'board-collab-invite',
|
|
45
|
+
subject: 'info',
|
|
46
|
+
title: 'Board collaboration invite',
|
|
47
|
+
body: `${who} invited you to collaborate on a board.`,
|
|
48
|
+
url: `board-modeller/${boardId}`,
|
|
49
|
+
mode: 'background'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* Every other conversation is not about a board. Both chat doors called this without one and every
|
|
55
|
+
* invite read "Board collaboration invite" - in operato-ecs, in plant, anywhere (2026-09-24).
|
|
56
|
+
*/
|
|
57
|
+
const name = sessionName?.trim()
|
|
38
58
|
return {
|
|
39
59
|
receivers: userIds,
|
|
40
|
-
type: '
|
|
60
|
+
type: 'chat-invite',
|
|
41
61
|
subject: 'info',
|
|
42
|
-
title: '
|
|
43
|
-
body: `${who}
|
|
44
|
-
// board 가 연결돼 있으면 모델러로 바로 가는 링크. 없으면 링크 생략.
|
|
45
|
-
url: boardId ? `board-modeller/${boardId}` : undefined,
|
|
62
|
+
title: 'Conversation invite',
|
|
63
|
+
body: name ? `${who} brought you into the conversation "${name}".` : `${who} brought you into a conversation.`,
|
|
46
64
|
mode: 'background'
|
|
47
65
|
}
|
|
48
66
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Being in the room - owner or member. A `viewer` row only remembers a hide. Pure, so it is tested alone. */
|
|
2
|
+
export const ROOM_ROLES = ['owner', 'member'] as const
|
|
3
|
+
|
|
4
|
+
export function isRoomRole(role: string | null | undefined): boolean {
|
|
5
|
+
return (ROOM_ROLES as readonly string[]).includes(role ?? '')
|
|
6
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Is this user in the room - owner or member?
|
|
3
|
+
*
|
|
4
|
+
* A `viewer` row is not being in the room: `hideAISession` writes one to remember a hide for someone who
|
|
5
|
+
* never took part. Counting it let a person who hid a private conversation by its id read it afterwards -
|
|
6
|
+
* the hide became the key. Every gate that asks "is this a participant" asks here.
|
|
7
|
+
*/
|
|
8
|
+
import { getRepository } from '@things-factory/shell'
|
|
9
|
+
|
|
10
|
+
import { ChatSessionParticipant } from './chat-session-participant.js'
|
|
11
|
+
|
|
12
|
+
import { isRoomRole } from './participant-lookup-roles.js'
|
|
13
|
+
|
|
14
|
+
export { ROOM_ROLES, isRoomRole } from './participant-lookup-roles.js'
|
|
15
|
+
|
|
16
|
+
export async function isSessionParticipant(domainId: string, sessionId: string, userId: string | undefined, tx?: any): Promise<boolean> {
|
|
17
|
+
if (!userId) return false
|
|
18
|
+
const row = await getRepository(ChatSessionParticipant, tx).findOne({
|
|
19
|
+
where: { domain: { id: domainId } as any, session: { id: sessionId } as any, user: { id: userId } as any }
|
|
20
|
+
})
|
|
21
|
+
return isRoomRole(row?.role)
|
|
22
|
+
}
|