s9n-devops-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/bin/cs-devops-agent +151 -0
- package/cleanup-sessions.sh +70 -0
- package/docs/PROJECT_INFO.md +115 -0
- package/docs/RELEASE_NOTES.md +189 -0
- package/docs/SESSION_MANAGEMENT.md +120 -0
- package/docs/TESTING.md +331 -0
- package/docs/houserules.md +267 -0
- package/docs/infrastructure.md +68 -0
- package/docs/testing-guide.md +224 -0
- package/package.json +68 -0
- package/src/agent-commands.js +211 -0
- package/src/claude-session-manager.js +488 -0
- package/src/close-session.js +316 -0
- package/src/cs-devops-agent-worker.js +1660 -0
- package/src/run-with-agent.js +372 -0
- package/src/session-coordinator.js +1207 -0
- package/src/setup-cs-devops-agent.js +985 -0
- package/src/worktree-manager.js +768 -0
- package/start-devops-session.sh +299 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session Cleanup Tool
|
|
5
|
+
* Safely closes and cleans up DevOps agent sessions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const readline = require('readline');
|
|
12
|
+
|
|
13
|
+
// Configuration
|
|
14
|
+
const CONFIG = {
|
|
15
|
+
colors: {
|
|
16
|
+
reset: '\x1b[0m',
|
|
17
|
+
bright: '\x1b[1m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[34m',
|
|
22
|
+
cyan: '\x1b[36m',
|
|
23
|
+
dim: '\x1b[2m'
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class SessionCloser {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.repoRoot = this.getRepoRoot();
|
|
30
|
+
this.locksPath = path.join(this.repoRoot, 'local_deploy', 'session-locks');
|
|
31
|
+
this.worktreesPath = path.join(this.repoRoot, 'local_deploy', 'worktrees');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getRepoRoot() {
|
|
35
|
+
try {
|
|
36
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(`${CONFIG.colors.red}Error: Not in a git repository${CONFIG.colors.reset}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* List all active sessions
|
|
45
|
+
*/
|
|
46
|
+
listSessions() {
|
|
47
|
+
if (!fs.existsSync(this.locksPath)) {
|
|
48
|
+
console.log(`${CONFIG.colors.yellow}No active sessions found${CONFIG.colors.reset}`);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sessions = [];
|
|
53
|
+
const lockFiles = fs.readdirSync(this.locksPath).filter(f => f.endsWith('.lock'));
|
|
54
|
+
|
|
55
|
+
lockFiles.forEach(file => {
|
|
56
|
+
const lockPath = path.join(this.locksPath, file);
|
|
57
|
+
const sessionData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
58
|
+
sessions.push(sessionData);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return sessions;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Display sessions for selection
|
|
66
|
+
*/
|
|
67
|
+
async selectSession() {
|
|
68
|
+
const sessions = this.listSessions();
|
|
69
|
+
|
|
70
|
+
if (sessions.length === 0) {
|
|
71
|
+
console.log(`${CONFIG.colors.yellow}No active sessions to close${CONFIG.colors.reset}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`\n${CONFIG.colors.bright}Active Sessions:${CONFIG.colors.reset}\n`);
|
|
76
|
+
|
|
77
|
+
sessions.forEach((session, index) => {
|
|
78
|
+
const status = session.status === 'active' ?
|
|
79
|
+
`${CONFIG.colors.green}ā${CONFIG.colors.reset}` :
|
|
80
|
+
`${CONFIG.colors.yellow}ā${CONFIG.colors.reset}`;
|
|
81
|
+
|
|
82
|
+
console.log(`${status} ${CONFIG.colors.bright}${index + 1})${CONFIG.colors.reset} ${session.sessionId}`);
|
|
83
|
+
console.log(` Task: ${session.task}`);
|
|
84
|
+
console.log(` Branch: ${session.branchName}`);
|
|
85
|
+
console.log(` Created: ${session.created}`);
|
|
86
|
+
console.log();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const rl = readline.createInterface({
|
|
90
|
+
input: process.stdin,
|
|
91
|
+
output: process.stdout
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
rl.question(`Select session to close (1-${sessions.length}) or 'q' to quit: `, (answer) => {
|
|
96
|
+
rl.close();
|
|
97
|
+
|
|
98
|
+
if (answer.toLowerCase() === 'q') {
|
|
99
|
+
resolve(null);
|
|
100
|
+
} else {
|
|
101
|
+
const index = parseInt(answer) - 1;
|
|
102
|
+
if (index >= 0 && index < sessions.length) {
|
|
103
|
+
resolve(sessions[index]);
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`${CONFIG.colors.red}Invalid selection${CONFIG.colors.reset}`);
|
|
106
|
+
resolve(null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Close a session with all cleanup
|
|
115
|
+
*/
|
|
116
|
+
async closeSession(session) {
|
|
117
|
+
console.log(`\n${CONFIG.colors.yellow}Closing session: ${session.sessionId}${CONFIG.colors.reset}`);
|
|
118
|
+
|
|
119
|
+
const steps = [
|
|
120
|
+
{ name: 'Kill agent process', fn: () => this.killAgent(session) },
|
|
121
|
+
{ name: 'Check for uncommitted changes', fn: () => this.checkUncommittedChanges(session) },
|
|
122
|
+
{ name: 'Push final changes', fn: () => this.pushChanges(session) },
|
|
123
|
+
{ name: 'Remove worktree', fn: () => this.removeWorktree(session) },
|
|
124
|
+
{ name: 'Delete local branch', fn: () => this.deleteLocalBranch(session) },
|
|
125
|
+
{ name: 'Clean up lock file', fn: () => this.removeLockFile(session) }
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
let allSuccess = true;
|
|
129
|
+
|
|
130
|
+
for (const step of steps) {
|
|
131
|
+
process.stdout.write(`${step.name}... `);
|
|
132
|
+
try {
|
|
133
|
+
const result = await step.fn();
|
|
134
|
+
if (result !== false) {
|
|
135
|
+
console.log(`${CONFIG.colors.green}ā${CONFIG.colors.reset}`);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.log(`${CONFIG.colors.red}ā ${error.message}${CONFIG.colors.reset}`);
|
|
139
|
+
allSuccess = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Ask about remote branch
|
|
144
|
+
const deleteRemote = await this.askDeleteRemote(session);
|
|
145
|
+
if (deleteRemote) {
|
|
146
|
+
process.stdout.write('Delete remote branch... ');
|
|
147
|
+
try {
|
|
148
|
+
this.deleteRemoteBranch(session);
|
|
149
|
+
console.log(`${CONFIG.colors.green}ā${CONFIG.colors.reset}`);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.log(`${CONFIG.colors.yellow}ā ${error.message}${CONFIG.colors.reset}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (allSuccess) {
|
|
156
|
+
console.log(`\n${CONFIG.colors.green}ā Session closed successfully!${CONFIG.colors.reset}`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`\n${CONFIG.colors.yellow}ā Session closed with warnings${CONFIG.colors.reset}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Kill the agent process if running
|
|
164
|
+
*/
|
|
165
|
+
killAgent(session) {
|
|
166
|
+
if (session.agentPid) {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(session.agentPid, 'SIGTERM');
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// Process might already be dead
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check for uncommitted changes
|
|
180
|
+
*/
|
|
181
|
+
checkUncommittedChanges(session) {
|
|
182
|
+
try {
|
|
183
|
+
const status = execSync(`git -C "${session.worktreePath}" status --porcelain`, { encoding: 'utf8' });
|
|
184
|
+
if (status.trim()) {
|
|
185
|
+
console.log(`\n${CONFIG.colors.yellow}Warning: Uncommitted changes in worktree:${CONFIG.colors.reset}`);
|
|
186
|
+
console.log(status);
|
|
187
|
+
|
|
188
|
+
// Optionally commit them
|
|
189
|
+
const rl = readline.createInterface({
|
|
190
|
+
input: process.stdin,
|
|
191
|
+
output: process.stdout
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
rl.question('Commit these changes? (y/n): ', (answer) => {
|
|
196
|
+
rl.close();
|
|
197
|
+
if (answer.toLowerCase() === 'y') {
|
|
198
|
+
execSync(`git -C "${session.worktreePath}" add -A`, { stdio: 'pipe' });
|
|
199
|
+
execSync(`git -C "${session.worktreePath}" commit -m "chore: final session cleanup"`, { stdio: 'pipe' });
|
|
200
|
+
}
|
|
201
|
+
resolve(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Worktree might not exist
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Push any final changes
|
|
213
|
+
*/
|
|
214
|
+
pushChanges(session) {
|
|
215
|
+
try {
|
|
216
|
+
// Check if there are unpushed commits
|
|
217
|
+
const unpushed = execSync(
|
|
218
|
+
`git -C "${session.worktreePath}" log origin/${session.branchName}..HEAD --oneline 2>/dev/null`,
|
|
219
|
+
{ encoding: 'utf8' }
|
|
220
|
+
).trim();
|
|
221
|
+
|
|
222
|
+
if (unpushed) {
|
|
223
|
+
execSync(`git -C "${session.worktreePath}" push origin ${session.branchName}`, { stdio: 'pipe' });
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// Branch might not exist on remote or worktree might be gone
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Remove the worktree
|
|
233
|
+
*/
|
|
234
|
+
removeWorktree(session) {
|
|
235
|
+
try {
|
|
236
|
+
execSync(`git worktree remove "${session.worktreePath}" --force`, { stdio: 'pipe' });
|
|
237
|
+
execSync('git worktree prune', { stdio: 'pipe' });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Worktree might already be removed
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Delete local branch
|
|
246
|
+
*/
|
|
247
|
+
deleteLocalBranch(session) {
|
|
248
|
+
try {
|
|
249
|
+
execSync(`git branch -D ${session.branchName}`, { stdio: 'pipe' });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// Branch might not exist locally
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Remove lock file
|
|
258
|
+
*/
|
|
259
|
+
removeLockFile(session) {
|
|
260
|
+
const lockFile = path.join(this.locksPath, `${session.sessionId}.lock`);
|
|
261
|
+
if (fs.existsSync(lockFile)) {
|
|
262
|
+
fs.unlinkSync(lockFile);
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Ask if remote branch should be deleted
|
|
269
|
+
*/
|
|
270
|
+
askDeleteRemote(session) {
|
|
271
|
+
const rl = readline.createInterface({
|
|
272
|
+
input: process.stdin,
|
|
273
|
+
output: process.stdout
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
rl.question(`\nDelete remote branch ${session.branchName}? (y/n): `, (answer) => {
|
|
278
|
+
rl.close();
|
|
279
|
+
resolve(answer.toLowerCase() === 'y');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Delete remote branch
|
|
286
|
+
*/
|
|
287
|
+
deleteRemoteBranch(session) {
|
|
288
|
+
execSync(`git push origin --delete ${session.branchName}`, { stdio: 'pipe' });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Main execution
|
|
294
|
+
*/
|
|
295
|
+
async run() {
|
|
296
|
+
console.log(`${CONFIG.colors.bright}\nš§ DevOps Session Cleanup Tool${CONFIG.colors.reset}`);
|
|
297
|
+
|
|
298
|
+
const session = await this.selectSession();
|
|
299
|
+
if (session) {
|
|
300
|
+
await this.closeSession(session);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`\n${CONFIG.colors.dim}No session selected${CONFIG.colors.reset}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Run if called directly
|
|
308
|
+
if (require.main === module) {
|
|
309
|
+
const closer = new SessionCloser();
|
|
310
|
+
closer.run().catch(error => {
|
|
311
|
+
console.error(`${CONFIG.colors.red}Error: ${error.message}${CONFIG.colors.reset}`);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = SessionCloser;
|