@pollit/twin-dev-bot 0.0.1 → 0.0.3

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.
@@ -1,247 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
2
- import { createLogger } from "./logger.js";
3
- import { SESSIONS_FILE } from "./paths.js";
4
- const log = createLogger("session-manager");
5
- /**
6
- * 세션 키 생성: projectName:threadTs
7
- */
8
- function makeSessionKey(projectName, threadTs) {
9
- return `${projectName}:${threadTs}`;
10
- }
11
- class SessionManager {
12
- sessions = new Map();
13
- // sessionKey (projectName:threadTs) -> sessionId 매핑
14
- sessionKeyToId = new Map();
15
- // threadTs -> sessionId 매핑 (스레드로 세션 찾기)
16
- threadToSession = new Map();
17
- loaded = false;
18
- cleanupInterval = null;
19
- // 24시간 동안 활동이 없는 세션은 자동 삭제
20
- static MAX_INACTIVE_MS = 24 * 60 * 60 * 1000;
21
- /**
22
- * 파일 로드를 지연 실행. 첫 접근 시 한 번만 호출됨.
23
- */
24
- ensureLoaded() {
25
- if (this.loaded)
26
- return;
27
- this.loaded = true;
28
- this.loadFromFile();
29
- this.startCleanupTimer();
30
- }
31
- /**
32
- * 파일에서 세션 로드
33
- */
34
- loadFromFile() {
35
- try {
36
- if (!existsSync(SESSIONS_FILE)) {
37
- log.info("No sessions file found, starting fresh");
38
- return;
39
- }
40
- const content = readFileSync(SESSIONS_FILE, "utf-8");
41
- let data;
42
- try {
43
- data = JSON.parse(content);
44
- }
45
- catch (parseError) {
46
- log.error("Sessions file is corrupted, starting fresh", { parseError });
47
- return;
48
- }
49
- if (!data.sessions || !Array.isArray(data.sessions)) {
50
- log.error("Sessions file has invalid structure, starting fresh");
51
- return;
52
- }
53
- let loadedCount = 0;
54
- for (const s of data.sessions) {
55
- try {
56
- const session = {
57
- sessionId: s.sessionId,
58
- projectName: s.projectName,
59
- directory: s.directory,
60
- slackChannelId: s.slackChannelId,
61
- slackThreadTs: s.slackThreadTs,
62
- startedAt: new Date(s.startedAt),
63
- lastActivityAt: new Date(s.lastActivityAt),
64
- autopilot: s.autopilot ?? false,
65
- };
66
- this.sessions.set(session.sessionId, session);
67
- const key = makeSessionKey(session.projectName, session.slackThreadTs);
68
- this.sessionKeyToId.set(key, session.sessionId);
69
- this.threadToSession.set(session.slackThreadTs, session.sessionId);
70
- loadedCount++;
71
- }
72
- catch (sessionError) {
73
- log.warn("Skipping invalid session entry", { entry: s, error: sessionError });
74
- }
75
- }
76
- log.info("Sessions loaded from file", { count: loadedCount, total: data.sessions.length });
77
- }
78
- catch (error) {
79
- log.error("Failed to load sessions from file", { error });
80
- }
81
- }
82
- /**
83
- * 파일에 세션 저장
84
- */
85
- saveToFile() {
86
- try {
87
- const sessions = Array.from(this.sessions.values()).map((s) => ({
88
- sessionId: s.sessionId,
89
- projectName: s.projectName,
90
- directory: s.directory,
91
- slackChannelId: s.slackChannelId,
92
- slackThreadTs: s.slackThreadTs,
93
- startedAt: s.startedAt.toISOString(),
94
- lastActivityAt: s.lastActivityAt.toISOString(),
95
- autopilot: s.autopilot ?? undefined,
96
- }));
97
- const data = {
98
- version: 1,
99
- sessions,
100
- };
101
- const tmpFile = SESSIONS_FILE + ".tmp";
102
- writeFileSync(tmpFile, JSON.stringify(data, null, 2));
103
- renameSync(tmpFile, SESSIONS_FILE);
104
- log.debug("Sessions saved to file", { count: sessions.length });
105
- return true;
106
- }
107
- catch (error) {
108
- log.error("Failed to save sessions to file", { error });
109
- return false;
110
- }
111
- }
112
- add(session) {
113
- this.ensureLoaded();
114
- this.sessions.set(session.sessionId, session);
115
- const key = makeSessionKey(session.projectName, session.slackThreadTs);
116
- this.sessionKeyToId.set(key, session.sessionId);
117
- this.threadToSession.set(session.slackThreadTs, session.sessionId);
118
- log.info("Session added", {
119
- sessionId: session.sessionId,
120
- projectName: session.projectName,
121
- threadTs: session.slackThreadTs,
122
- sessionKey: key,
123
- });
124
- this.saveToFile();
125
- }
126
- get(sessionId) {
127
- this.ensureLoaded();
128
- return this.sessions.get(sessionId);
129
- }
130
- /**
131
- * projectName과 threadTs로 세션 조회
132
- */
133
- getBySessionKey(projectName, threadTs) {
134
- this.ensureLoaded();
135
- const key = makeSessionKey(projectName, threadTs);
136
- const sessionId = this.sessionKeyToId.get(key);
137
- if (sessionId) {
138
- return this.sessions.get(sessionId);
139
- }
140
- return undefined;
141
- }
142
- getByThread(threadTs) {
143
- this.ensureLoaded();
144
- const sessionId = this.threadToSession.get(threadTs);
145
- if (sessionId) {
146
- return this.sessions.get(sessionId);
147
- }
148
- return undefined;
149
- }
150
- updateActivity(sessionId) {
151
- this.ensureLoaded();
152
- const session = this.sessions.get(sessionId);
153
- if (session) {
154
- session.lastActivityAt = new Date();
155
- this.saveToFile();
156
- }
157
- }
158
- /**
159
- * threadTs로 세션의 autopilot 플래그 변경
160
- */
161
- setAutopilot(threadTs, value) {
162
- this.ensureLoaded();
163
- const sessionId = this.threadToSession.get(threadTs);
164
- if (!sessionId)
165
- return;
166
- const session = this.sessions.get(sessionId);
167
- if (session) {
168
- session.autopilot = value;
169
- this.saveToFile();
170
- log.info("Session autopilot updated", { sessionId, threadTs, autopilot: value });
171
- }
172
- }
173
- remove(sessionId) {
174
- this.ensureLoaded();
175
- const session = this.sessions.get(sessionId);
176
- if (session) {
177
- const key = makeSessionKey(session.projectName, session.slackThreadTs);
178
- this.sessionKeyToId.delete(key);
179
- this.threadToSession.delete(session.slackThreadTs);
180
- this.sessions.delete(sessionId);
181
- log.info("Session removed", { sessionId });
182
- this.saveToFile();
183
- }
184
- }
185
- getActiveCount() {
186
- this.ensureLoaded();
187
- return this.sessions.size;
188
- }
189
- /**
190
- * 비활성 세션 정리 (lastActivityAt 기준)
191
- */
192
- cleanup() {
193
- this.ensureLoaded();
194
- const now = Date.now();
195
- let cleanedCount = 0;
196
- for (const [sessionId, session] of this.sessions.entries()) {
197
- const inactiveMs = now - session.lastActivityAt.getTime();
198
- if (inactiveMs > SessionManager.MAX_INACTIVE_MS) {
199
- const key = makeSessionKey(session.projectName, session.slackThreadTs);
200
- this.sessionKeyToId.delete(key);
201
- this.threadToSession.delete(session.slackThreadTs);
202
- this.sessions.delete(sessionId);
203
- cleanedCount++;
204
- log.info("Session expired due to inactivity", {
205
- sessionId,
206
- projectName: session.projectName,
207
- inactiveDays: Math.floor(inactiveMs / (24 * 60 * 60 * 1000)),
208
- });
209
- }
210
- }
211
- if (cleanedCount > 0) {
212
- this.saveToFile();
213
- log.info("Session cleanup completed", { cleanedCount, remaining: this.sessions.size });
214
- }
215
- return cleanedCount;
216
- }
217
- /**
218
- * 주기적 정리 타이머 시작 (매 시간마다)
219
- */
220
- startCleanupTimer() {
221
- if (this.cleanupInterval)
222
- return;
223
- // 매 시간마다 실행 (첫 실행은 1시간 후)
224
- this.cleanupInterval = setInterval(() => {
225
- this.cleanup();
226
- }, 60 * 60 * 1000);
227
- this.cleanupInterval.unref();
228
- log.debug("Session cleanup timer started (runs every hour)");
229
- }
230
- /**
231
- * 정리 타이머 중지 (주로 테스트용)
232
- */
233
- stopCleanupTimer() {
234
- if (this.cleanupInterval) {
235
- clearInterval(this.cleanupInterval);
236
- this.cleanupInterval = null;
237
- log.debug("Session cleanup timer stopped");
238
- }
239
- }
240
- /**
241
- * 테스트용: cleanup 수동 실행
242
- */
243
- runCleanup() {
244
- return this.cleanup();
245
- }
246
- }
247
- export const sessionManager = new SessionManager();
@@ -1,27 +0,0 @@
1
- /**
2
- * Workspace Store
3
- *
4
- * /twindevbot goto 로 생성된 스레드와 작업 디렉토리의 매핑을 관리합니다.
5
- * 스레드에 첫 메시지가 오면 이 매핑을 사용하여 Claude 세션을 시작합니다.
6
- *
7
- * 키: threadTs (Slack 스레드 부모 메시지 타임스탬프)
8
- * 값: { directory, projectName, channelId }
9
- */
10
- export interface Workspace {
11
- directory: string;
12
- projectName: string;
13
- channelId: string;
14
- autopilot?: boolean;
15
- createdAt?: Date;
16
- }
17
- export declare function addWorkspace(threadTs: string, workspace: Workspace): void;
18
- export declare function getWorkspace(threadTs: string): Workspace | undefined;
19
- export declare function removeWorkspace(threadTs: string): void;
20
- /**
21
- * 정리 타이머 중지 (주로 테스트용)
22
- */
23
- export declare function stopCleanupTimer(): void;
24
- /**
25
- * 테스트용: cleanup 수동 실행
26
- */
27
- export declare function runCleanup(): number;
@@ -1,160 +0,0 @@
1
- /**
2
- * Workspace Store
3
- *
4
- * /twindevbot goto 로 생성된 스레드와 작업 디렉토리의 매핑을 관리합니다.
5
- * 스레드에 첫 메시지가 오면 이 매핑을 사용하여 Claude 세션을 시작합니다.
6
- *
7
- * 키: threadTs (Slack 스레드 부모 메시지 타임스탬프)
8
- * 값: { directory, projectName, channelId }
9
- */
10
- import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
11
- import { createLogger } from "./logger.js";
12
- import { WORKSPACES_FILE } from "./paths.js";
13
- const log = createLogger("workspace-store");
14
- const workspaces = new Map();
15
- let cleanupInterval = null;
16
- // 24시간이 지난 워크스페이스는 자동 삭제
17
- const MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
- function loadFromFile() {
19
- try {
20
- if (!existsSync(WORKSPACES_FILE)) {
21
- log.info("No workspaces file found, starting fresh");
22
- return;
23
- }
24
- const content = readFileSync(WORKSPACES_FILE, "utf-8");
25
- let data;
26
- try {
27
- data = JSON.parse(content);
28
- }
29
- catch (parseError) {
30
- log.error("Workspaces file is corrupted, starting fresh", { parseError });
31
- return;
32
- }
33
- if (!data.workspaces || !Array.isArray(data.workspaces)) {
34
- log.error("Workspaces file has invalid structure, starting fresh");
35
- return;
36
- }
37
- let loadedCount = 0;
38
- for (const w of data.workspaces) {
39
- try {
40
- workspaces.set(w.threadTs, {
41
- directory: w.directory,
42
- projectName: w.projectName,
43
- channelId: w.channelId,
44
- autopilot: w.autopilot,
45
- createdAt: w.createdAt ? new Date(w.createdAt) : new Date(),
46
- });
47
- loadedCount++;
48
- }
49
- catch (entryError) {
50
- log.warn("Skipping invalid workspace entry", { entry: w, error: entryError });
51
- }
52
- }
53
- log.info("Workspaces loaded from file", { count: loadedCount, total: data.workspaces.length });
54
- }
55
- catch (error) {
56
- log.error("Failed to load workspaces from file", { error });
57
- }
58
- }
59
- function saveToFile() {
60
- try {
61
- const serialized = Array.from(workspaces.entries()).map(([threadTs, w]) => ({
62
- threadTs,
63
- directory: w.directory,
64
- projectName: w.projectName,
65
- channelId: w.channelId,
66
- autopilot: w.autopilot ?? undefined,
67
- createdAt: w.createdAt?.toISOString(),
68
- }));
69
- const data = {
70
- version: 1,
71
- workspaces: serialized,
72
- };
73
- const tmpFile = WORKSPACES_FILE + ".tmp";
74
- writeFileSync(tmpFile, JSON.stringify(data, null, 2));
75
- renameSync(tmpFile, WORKSPACES_FILE);
76
- log.debug("Workspaces saved to file", { count: serialized.length });
77
- }
78
- catch (error) {
79
- log.error("Failed to save workspaces to file", { error });
80
- }
81
- }
82
- // 모듈 로드 시 파일에서 복원
83
- loadFromFile();
84
- // 정리 타이머 시작
85
- function startCleanupTimer() {
86
- if (cleanupInterval)
87
- return;
88
- // 매 시간마다 실행 (첫 실행은 1시간 후)
89
- cleanupInterval = setInterval(() => {
90
- cleanup();
91
- }, 60 * 60 * 1000);
92
- cleanupInterval.unref();
93
- log.debug("Workspace cleanup timer started (runs every hour)");
94
- }
95
- startCleanupTimer();
96
- export function addWorkspace(threadTs, workspace) {
97
- // createdAt이 없으면 현재 시간으로 설정
98
- if (!workspace.createdAt) {
99
- workspace.createdAt = new Date();
100
- }
101
- workspaces.set(threadTs, workspace);
102
- log.info("Workspace registered", {
103
- threadTs,
104
- projectName: workspace.projectName,
105
- directory: workspace.directory,
106
- });
107
- saveToFile();
108
- }
109
- export function getWorkspace(threadTs) {
110
- return workspaces.get(threadTs);
111
- }
112
- export function removeWorkspace(threadTs) {
113
- workspaces.delete(threadTs);
114
- log.debug("Workspace removed", { threadTs });
115
- saveToFile();
116
- }
117
- /**
118
- * 오래된 워크스페이스 정리 (createdAt 기준)
119
- */
120
- function cleanup() {
121
- const now = Date.now();
122
- let cleanedCount = 0;
123
- for (const [threadTs, workspace] of workspaces.entries()) {
124
- // createdAt이 없는 항목은 건너뜀 (레거시 데이터)
125
- if (!workspace.createdAt) {
126
- continue;
127
- }
128
- const ageMs = now - workspace.createdAt.getTime();
129
- if (ageMs > MAX_AGE_MS) {
130
- workspaces.delete(threadTs);
131
- cleanedCount++;
132
- log.info("Workspace expired", {
133
- threadTs,
134
- projectName: workspace.projectName,
135
- ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
136
- });
137
- }
138
- }
139
- if (cleanedCount > 0) {
140
- saveToFile();
141
- log.info("Workspace cleanup completed", { cleanedCount, remaining: workspaces.size });
142
- }
143
- return cleanedCount;
144
- }
145
- /**
146
- * 정리 타이머 중지 (주로 테스트용)
147
- */
148
- export function stopCleanupTimer() {
149
- if (cleanupInterval) {
150
- clearInterval(cleanupInterval);
151
- cleanupInterval = null;
152
- log.debug("Workspace cleanup timer stopped");
153
- }
154
- }
155
- /**
156
- * 테스트용: cleanup 수동 실행
157
- */
158
- export function runCleanup() {
159
- return cleanup();
160
- }