create-openthrottle 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/index.mjs +335 -0
- package/package.json +33 -0
- package/templates/docker/Dockerfile +25 -0
- package/templates/docker/agent-lib.sh +223 -0
- package/templates/docker/entrypoint.sh +285 -0
- package/templates/docker/git-hooks/pre-push +15 -0
- package/templates/docker/hooks/auto-format.sh +44 -0
- package/templates/docker/hooks/block-push-to-main.sh +116 -0
- package/templates/docker/hooks/log-commands.sh +48 -0
- package/templates/docker/run-builder.sh +351 -0
- package/templates/docker/run-reviewer.sh +251 -0
- package/templates/docker/task-adapter.sh +123 -0
- package/templates/wake-sandbox.yml +146 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// create-openthrottle — Set up openthrottle in any Node.js project.
|
|
4
|
+
//
|
|
5
|
+
// Usage: npx create-openthrottle
|
|
6
|
+
//
|
|
7
|
+
// Detects the project, prompts for config, generates .openthrottle.yml +
|
|
8
|
+
// wake-sandbox.yml, creates a Daytona snapshot + volume, prints next steps.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import prompts from 'prompts';
|
|
16
|
+
import { stringify } from 'yaml';
|
|
17
|
+
import { Daytona } from '@daytonaio/sdk';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// 1. Detect project
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function detectProject() {
|
|
27
|
+
const pkgPath = join(cwd, 'package.json');
|
|
28
|
+
if (!existsSync(pkgPath)) {
|
|
29
|
+
console.error('No package.json found. create-openthrottle currently supports Node.js projects only.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let pkg;
|
|
34
|
+
try {
|
|
35
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
console.error('Could not parse package.json. Is it valid JSON?');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const scripts = pkg.scripts || {};
|
|
41
|
+
const rawName = pkg.name?.replace(/^@[^/]+\//, '') || 'project';
|
|
42
|
+
const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
43
|
+
|
|
44
|
+
// Detect package manager
|
|
45
|
+
let pm = 'npm';
|
|
46
|
+
if (pkg.packageManager?.startsWith('pnpm')) pm = 'pnpm';
|
|
47
|
+
else if (pkg.packageManager?.startsWith('yarn')) pm = 'yarn';
|
|
48
|
+
else if (existsSync(join(cwd, 'pnpm-lock.yaml'))) pm = 'pnpm';
|
|
49
|
+
else if (existsSync(join(cwd, 'yarn.lock'))) pm = 'yarn';
|
|
50
|
+
else if (existsSync(join(cwd, 'package-lock.json'))) pm = 'npm';
|
|
51
|
+
|
|
52
|
+
// Detect base branch
|
|
53
|
+
let baseBranch = 'main';
|
|
54
|
+
try {
|
|
55
|
+
const head = execFileSync('git', ['remote', 'show', 'origin'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
56
|
+
const match = head.match(/HEAD branch:\s*(\S+)/);
|
|
57
|
+
if (match) baseBranch = match[1];
|
|
58
|
+
} catch {
|
|
59
|
+
// Not a git repo or no remote — default to main
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
pm,
|
|
65
|
+
baseBranch,
|
|
66
|
+
test: scripts.test ? `${pm} test` : '',
|
|
67
|
+
build: scripts.build ? `${pm} build` : '',
|
|
68
|
+
lint: scripts.lint ? `${pm} lint` : '',
|
|
69
|
+
format: scripts.format ? `${pm} run format` : (pkg.devDependencies?.prettier ? 'npx prettier --write .' : ''),
|
|
70
|
+
dev: scripts.dev ? `${pm} dev --port 8080 --hostname 0.0.0.0` : '',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 2. Prompt for config
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async function promptConfig(detected) {
|
|
79
|
+
console.log(`\n Detected: package.json (${detected.pm})\n`);
|
|
80
|
+
|
|
81
|
+
const response = await prompts([
|
|
82
|
+
{ type: 'text', name: 'baseBranch', message: 'Base branch', initial: detected.baseBranch },
|
|
83
|
+
{ type: 'text', name: 'test', message: 'Test command', initial: detected.test },
|
|
84
|
+
{ type: 'text', name: 'build', message: 'Build command', initial: detected.build },
|
|
85
|
+
{ type: 'text', name: 'lint', message: 'Lint command', initial: detected.lint },
|
|
86
|
+
{ type: 'text', name: 'format', message: 'Format command', initial: detected.format },
|
|
87
|
+
{ type: 'text', name: 'dev', message: 'Dev command', initial: detected.dev },
|
|
88
|
+
{ type: 'text', name: 'postBootstrap', message: 'Post-bootstrap command', initial: `${detected.pm} install` },
|
|
89
|
+
{
|
|
90
|
+
type: 'select', name: 'agent', message: 'Agent runtime',
|
|
91
|
+
choices: [
|
|
92
|
+
{ title: 'Claude', value: 'claude' },
|
|
93
|
+
{ title: 'Codex', value: 'codex' },
|
|
94
|
+
{ title: 'Aider', value: 'aider' },
|
|
95
|
+
],
|
|
96
|
+
initial: 0,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: 'select', name: 'notifications', message: 'Notifications',
|
|
100
|
+
choices: [
|
|
101
|
+
{ title: 'Telegram', value: 'telegram' },
|
|
102
|
+
{ title: 'None', value: 'none' },
|
|
103
|
+
],
|
|
104
|
+
initial: 0,
|
|
105
|
+
},
|
|
106
|
+
{ type: 'confirm', name: 'reviewEnabled', message: 'Enable automated PR review?', initial: true },
|
|
107
|
+
{
|
|
108
|
+
type: (prev) => prev ? 'number' : null,
|
|
109
|
+
name: 'maxRounds', message: 'Max review rounds', initial: 3, min: 1, max: 10,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'select', name: 'snapshotMode', message: 'Snapshot source',
|
|
113
|
+
choices: [
|
|
114
|
+
{ title: 'Pre-built image (faster, recommended)', value: 'image' },
|
|
115
|
+
{ title: 'Build from Dockerfile (customizable)', value: 'dockerfile' },
|
|
116
|
+
],
|
|
117
|
+
initial: 0,
|
|
118
|
+
},
|
|
119
|
+
], { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
120
|
+
|
|
121
|
+
return { ...detected, ...response };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// 3. Generate .openthrottle.yml
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function generateConfig(config) {
|
|
129
|
+
const doc = {
|
|
130
|
+
base_branch: config.baseBranch,
|
|
131
|
+
test: config.test || undefined,
|
|
132
|
+
dev: config.dev || undefined,
|
|
133
|
+
format: config.format || undefined,
|
|
134
|
+
lint: config.lint || undefined,
|
|
135
|
+
build: config.build || undefined,
|
|
136
|
+
notifications: config.notifications === 'none' ? undefined : config.notifications,
|
|
137
|
+
agent: config.agent,
|
|
138
|
+
snapshot: `openthrottle`,
|
|
139
|
+
post_bootstrap: [config.postBootstrap],
|
|
140
|
+
mcp_servers: {},
|
|
141
|
+
review: {
|
|
142
|
+
enabled: config.reviewEnabled,
|
|
143
|
+
max_rounds: config.maxRounds ?? 3,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Remove undefined fields
|
|
148
|
+
for (const key of Object.keys(doc)) {
|
|
149
|
+
if (doc[key] === undefined) delete doc[key];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const header = [
|
|
153
|
+
'# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
|
|
154
|
+
'# Generated by npx create-openthrottle. Committed to the repo so the',
|
|
155
|
+
'# sandbox knows how to work with this project.',
|
|
156
|
+
'',
|
|
157
|
+
].join('\n');
|
|
158
|
+
|
|
159
|
+
return header + stringify(doc);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 4. Copy wake-sandbox.yml
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function copyWorkflow() {
|
|
167
|
+
const src = join(__dirname, 'templates', 'wake-sandbox.yml');
|
|
168
|
+
const destDir = join(cwd, '.github', 'workflows');
|
|
169
|
+
const dest = join(destDir, 'wake-sandbox.yml');
|
|
170
|
+
mkdirSync(destDir, { recursive: true });
|
|
171
|
+
copyFileSync(src, dest);
|
|
172
|
+
return dest;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// 5 & 6. Create Daytona snapshot + volume
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async function setupDaytona(config) {
|
|
180
|
+
const apiKey = process.env.DAYTONA_API_KEY;
|
|
181
|
+
if (!apiKey) {
|
|
182
|
+
console.error('\n Missing DAYTONA_API_KEY env var. Get one at https://daytona.io/dashboard\n');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const daytona = new Daytona();
|
|
187
|
+
const snapshotName = `openthrottle`;
|
|
188
|
+
const volumeName = `openthrottle-${config.name}`;
|
|
189
|
+
|
|
190
|
+
// Create snapshot — from pre-built image or from Dockerfile
|
|
191
|
+
if (config.snapshotMode === 'dockerfile') {
|
|
192
|
+
// Copy Dockerfile + runtime scripts into user's project for customization
|
|
193
|
+
const dockerDir = join(cwd, '.openthrottle', 'docker');
|
|
194
|
+
mkdirSync(dockerDir, { recursive: true });
|
|
195
|
+
|
|
196
|
+
const templateDir = join(__dirname, 'templates', 'docker');
|
|
197
|
+
if (existsSync(templateDir)) {
|
|
198
|
+
for (const file of readdirSync(templateDir, { recursive: true })) {
|
|
199
|
+
const src = join(templateDir, file);
|
|
200
|
+
const dest = join(dockerDir, file);
|
|
201
|
+
const stat = statSync(src);
|
|
202
|
+
if (stat.isDirectory()) {
|
|
203
|
+
mkdirSync(dest, { recursive: true });
|
|
204
|
+
} else {
|
|
205
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
206
|
+
copyFileSync(src, dest);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
console.log(' ✓ Copied Dockerfile + runtime to .openthrottle/docker/');
|
|
211
|
+
console.log(' Customize the Dockerfile, then create snapshot:');
|
|
212
|
+
console.log(` daytona snapshot create ${snapshotName} --dockerfile .openthrottle/docker/Dockerfile --build-arg AGENT=${config.agent}`);
|
|
213
|
+
} else {
|
|
214
|
+
const image = `ghcr.io/openthrottle/doer-${config.agent}:node-1.0.0`;
|
|
215
|
+
try {
|
|
216
|
+
await daytona.snapshot.create({
|
|
217
|
+
name: snapshotName,
|
|
218
|
+
image,
|
|
219
|
+
resources: { cpu: 2, memory: 4, disk: 10 },
|
|
220
|
+
});
|
|
221
|
+
console.log(` ✓ Created Daytona snapshot: ${snapshotName}`);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err.status === 409 || err.message?.includes('already exists')) {
|
|
224
|
+
console.log(` ✓ Snapshot already exists: ${snapshotName}`);
|
|
225
|
+
} else {
|
|
226
|
+
console.error(` ✗ Failed to create snapshot: ${err.message}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Create volume (idempotent)
|
|
233
|
+
try {
|
|
234
|
+
await daytona.volume.create({ name: volumeName });
|
|
235
|
+
console.log(` ✓ Created Daytona volume: ${volumeName}`);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err.status === 409 || err.message?.includes('already exists')) {
|
|
238
|
+
console.log(` ✓ Volume already exists: ${volumeName}`);
|
|
239
|
+
} else {
|
|
240
|
+
console.error(` ✗ Failed to create volume: ${err.message}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { snapshotName, volumeName };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// 7. Print next steps
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function printNextSteps(config) {
|
|
253
|
+
const agentSecret =
|
|
254
|
+
config.agent === 'claude'
|
|
255
|
+
? ' ANTHROPIC_API_KEY ← option a: pay-per-use API key\n CLAUDE_CODE_OAUTH_TOKEN ← option b: subscription token (claude setup-token)'
|
|
256
|
+
: config.agent === 'codex'
|
|
257
|
+
? ' OPENAI_API_KEY ← required for Codex'
|
|
258
|
+
: ' OPENAI_API_KEY ← or ANTHROPIC_API_KEY (depends on your Aider model)';
|
|
259
|
+
const secrets = [
|
|
260
|
+
' DAYTONA_API_KEY ← required',
|
|
261
|
+
agentSecret,
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
console.log(`
|
|
265
|
+
Next steps:
|
|
266
|
+
|
|
267
|
+
1. Set GitHub repo secrets:
|
|
268
|
+
${secrets.join('\n')}
|
|
269
|
+
TELEGRAM_BOT_TOKEN ← optional (notifications)
|
|
270
|
+
TELEGRAM_CHAT_ID ← optional (notifications)
|
|
271
|
+
|
|
272
|
+
2. Commit and push:
|
|
273
|
+
git add .openthrottle.yml .github/workflows/wake-sandbox.yml
|
|
274
|
+
git commit -m "feat: add openthrottle config"
|
|
275
|
+
git push
|
|
276
|
+
|
|
277
|
+
3. Ship your first prompt:
|
|
278
|
+
gh issue create --title "My first feature" \\
|
|
279
|
+
--body-file docs/prds/my-feature.md \\
|
|
280
|
+
--label prd-queued
|
|
281
|
+
`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Main
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
async function main() {
|
|
289
|
+
console.log('\n create-openthrottle\n');
|
|
290
|
+
|
|
291
|
+
// Step 1: Detect
|
|
292
|
+
const detected = detectProject();
|
|
293
|
+
|
|
294
|
+
// Step 2: Prompt
|
|
295
|
+
const config = await promptConfig(detected);
|
|
296
|
+
|
|
297
|
+
// Step 3: Generate config
|
|
298
|
+
const configPath = join(cwd, '.openthrottle.yml');
|
|
299
|
+
if (existsSync(configPath)) {
|
|
300
|
+
const { overwrite } = await prompts({
|
|
301
|
+
type: 'confirm', name: 'overwrite',
|
|
302
|
+
message: '.openthrottle.yml already exists. Overwrite?', initial: false,
|
|
303
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
304
|
+
if (!overwrite) { console.log(' Skipped .openthrottle.yml'); }
|
|
305
|
+
else { writeFileSync(configPath, generateConfig(config)); console.log(' ✓ Generated .openthrottle.yml'); }
|
|
306
|
+
} else {
|
|
307
|
+
writeFileSync(configPath, generateConfig(config));
|
|
308
|
+
console.log(' ✓ Generated .openthrottle.yml');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Step 4: Copy workflow
|
|
312
|
+
const workflowPath = join(cwd, '.github', 'workflows', 'wake-sandbox.yml');
|
|
313
|
+
if (existsSync(workflowPath)) {
|
|
314
|
+
const { overwrite } = await prompts({
|
|
315
|
+
type: 'confirm', name: 'overwrite',
|
|
316
|
+
message: 'wake-sandbox.yml already exists. Overwrite?', initial: false,
|
|
317
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
318
|
+
if (!overwrite) { console.log(' Skipped wake-sandbox.yml'); }
|
|
319
|
+
else { copyWorkflow(); console.log(' ✓ Copied .github/workflows/wake-sandbox.yml'); }
|
|
320
|
+
} else {
|
|
321
|
+
copyWorkflow();
|
|
322
|
+
console.log(' ✓ Copied .github/workflows/wake-sandbox.yml');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Steps 5 & 6: Daytona setup
|
|
326
|
+
await setupDaytona(config);
|
|
327
|
+
|
|
328
|
+
// Step 7: Next steps
|
|
329
|
+
printNextSteps(config);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
main().catch((err) => {
|
|
333
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-openthrottle",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Set up openthrottle in any Node.js project — agent-agnostic, config-driven.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-openthrottle": "./index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"templates/"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@daytonaio/sdk": "^0.153.0",
|
|
15
|
+
"prompts": "^2.4.2",
|
|
16
|
+
"yaml": "^2.4.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/knoxgraeme/sodaprompts"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"openthrottle",
|
|
28
|
+
"daytona",
|
|
29
|
+
"agent",
|
|
30
|
+
"scaffolder",
|
|
31
|
+
"create"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
FROM daytonaio/sandbox:0.6.0
|
|
2
|
+
|
|
3
|
+
# Base image includes: Chromium, Xvfb, xfce4, Node.js, Claude Code, Python, git, curl
|
|
4
|
+
# Build remotely: daytona snapshot create openthrottle --dockerfile ./Dockerfile --context .
|
|
5
|
+
|
|
6
|
+
ARG AGENT=claude
|
|
7
|
+
|
|
8
|
+
USER root
|
|
9
|
+
|
|
10
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
11
|
+
gh jq gosu \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
13
|
+
&& npm install -g pnpm agent-browser \
|
|
14
|
+
&& if [ "$AGENT" = "codex" ]; then npm install -g @openai/codex; fi \
|
|
15
|
+
&& if [ "$AGENT" = "aider" ]; then pip install --no-cache-dir aider-chat; fi \
|
|
16
|
+
&& curl -sL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" \
|
|
17
|
+
-o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
|
|
18
|
+
|
|
19
|
+
COPY entrypoint.sh run-builder.sh run-reviewer.sh task-adapter.sh agent-lib.sh /opt/openthrottle/
|
|
20
|
+
COPY hooks/ /opt/openthrottle/hooks/
|
|
21
|
+
COPY git-hooks/ /opt/openthrottle/git-hooks/
|
|
22
|
+
|
|
23
|
+
RUN chmod +x /opt/openthrottle/*.sh /opt/openthrottle/hooks/*.sh /opt/openthrottle/git-hooks/*
|
|
24
|
+
|
|
25
|
+
ENTRYPOINT ["/opt/openthrottle/entrypoint.sh"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# agent-lib.sh — Shared library for Daytona sandbox runners
|
|
4
|
+
#
|
|
5
|
+
# Sourced by run-builder.sh and run-reviewer.sh. Contains:
|
|
6
|
+
# - log / notify — logging and Telegram notifications
|
|
7
|
+
# - sanitize_secrets — redact secrets from text
|
|
8
|
+
# - invoke_agent — runtime-specific agent invocation with session management
|
|
9
|
+
#
|
|
10
|
+
# Requires: SANDBOX_HOME, LOG_DIR, SESSIONS_DIR, AGENT_RUNTIME, GITHUB_REPO
|
|
11
|
+
# =============================================================================
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Logging and notifications
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
RUNNER_NAME="${RUNNER_NAME:-agent}"
|
|
17
|
+
|
|
18
|
+
log() { echo "[${RUNNER_NAME} $(date +%H:%M:%S)] $1" | tee -a "${LOG_DIR}/${RUNNER_NAME}.log"; }
|
|
19
|
+
|
|
20
|
+
notify() {
|
|
21
|
+
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_CHAT_ID:-}" ]]; then
|
|
22
|
+
local HTTP_CODE
|
|
23
|
+
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
24
|
+
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
|
25
|
+
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
|
26
|
+
-d "text=$1") || true
|
|
27
|
+
if [[ "$HTTP_CODE" != "200" ]] && [[ "${_NOTIFY_WARNED:-}" != "1" ]]; then
|
|
28
|
+
log "WARNING: Telegram notification failed (HTTP ${HTTP_CODE}). Check TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID."
|
|
29
|
+
_NOTIFY_WARNED=1
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Sanitize secrets from text before posting to GitHub or logs
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
sanitize_secrets() {
|
|
38
|
+
local TEXT="$1"
|
|
39
|
+
[[ -n "${GITHUB_TOKEN:-}" ]] && TEXT="${TEXT//$GITHUB_TOKEN/[REDACTED]}"
|
|
40
|
+
[[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && TEXT="${TEXT//$TELEGRAM_BOT_TOKEN/[REDACTED]}"
|
|
41
|
+
[[ -n "${ANTHROPIC_API_KEY:-}" ]] && TEXT="${TEXT//$ANTHROPIC_API_KEY/[REDACTED]}"
|
|
42
|
+
[[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]] && TEXT="${TEXT//$CLAUDE_CODE_OAUTH_TOKEN/[REDACTED]}"
|
|
43
|
+
[[ -n "${SUPABASE_ACCESS_TOKEN:-}" ]] && TEXT="${TEXT//$SUPABASE_ACCESS_TOKEN/[REDACTED]}"
|
|
44
|
+
[[ -n "${OPENAI_API_KEY:-}" ]] && TEXT="${TEXT//$OPENAI_API_KEY/[REDACTED]}"
|
|
45
|
+
TEXT=$(echo "$TEXT" | sed \
|
|
46
|
+
-e 's/ghp_[A-Za-z0-9_]\{36,\}/[REDACTED]/g' \
|
|
47
|
+
-e 's/ghs_[A-Za-z0-9_]\{36,\}/[REDACTED]/g' \
|
|
48
|
+
-e 's/sk-[A-Za-z0-9_-]\{20,\}/[REDACTED]/g' \
|
|
49
|
+
-e 's/Bearer [^ ]*/Bearer [REDACTED]/g')
|
|
50
|
+
echo "$TEXT"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Invoke the agent — runtime-specific command with session management
|
|
55
|
+
#
|
|
56
|
+
# Usage: invoke_agent PROMPT TIMEOUT SESSION_LOG [TASK_KEY]
|
|
57
|
+
# TASK_KEY — unique key for session persistence (e.g. "prd-42", "review-7").
|
|
58
|
+
# Session files are stored on the volume at SESSIONS_DIR.
|
|
59
|
+
# If RESUME_SESSION env var is set, it takes precedence (review-fix flow).
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
invoke_agent() {
|
|
62
|
+
local PROMPT="$1"
|
|
63
|
+
local AGENT_TIMEOUT="$2"
|
|
64
|
+
local SESSION_LOG="$3"
|
|
65
|
+
local TASK_KEY="${4:-}"
|
|
66
|
+
|
|
67
|
+
local -a SESSION_FLAGS=()
|
|
68
|
+
local ACTIVE_SESSION_ID=""
|
|
69
|
+
|
|
70
|
+
# Priority 1: RESUME_SESSION env var (set by GitHub Action for review-fix flow)
|
|
71
|
+
if [[ -n "${RESUME_SESSION:-}" ]]; then
|
|
72
|
+
SESSION_FLAGS=(--resume "$RESUME_SESSION")
|
|
73
|
+
ACTIVE_SESSION_ID="$RESUME_SESSION"
|
|
74
|
+
log "Resuming session from workflow: ${RESUME_SESSION}"
|
|
75
|
+
|
|
76
|
+
# Priority 2: Session file on volume (cross-sandbox resume)
|
|
77
|
+
elif [[ -n "$TASK_KEY" ]]; then
|
|
78
|
+
local SESSION_FILE="${SESSIONS_DIR}/${TASK_KEY}.id"
|
|
79
|
+
if [[ -f "$SESSION_FILE" ]]; then
|
|
80
|
+
local EXISTING_ID
|
|
81
|
+
EXISTING_ID=$(<"$SESSION_FILE")
|
|
82
|
+
if [[ -n "$EXISTING_ID" ]]; then
|
|
83
|
+
touch "$SESSION_FILE" # refresh mtime to prevent 7-day pruning
|
|
84
|
+
SESSION_FLAGS=(--resume "$EXISTING_ID")
|
|
85
|
+
ACTIVE_SESSION_ID="$EXISTING_ID"
|
|
86
|
+
log "Resuming session from volume: ${EXISTING_ID}"
|
|
87
|
+
else
|
|
88
|
+
log "WARNING: Empty session file for ${TASK_KEY} — starting fresh"
|
|
89
|
+
rm -f "$SESSION_FILE"
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# Create new session if not resuming
|
|
94
|
+
if [[ ${#SESSION_FLAGS[@]} -eq 0 ]]; then
|
|
95
|
+
local NEW_ID
|
|
96
|
+
NEW_ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
|
97
|
+
echo "$NEW_ID" > "${SESSIONS_DIR}/${TASK_KEY}.id"
|
|
98
|
+
SESSION_FLAGS=(--session-id "$NEW_ID")
|
|
99
|
+
ACTIVE_SESSION_ID="$NEW_ID"
|
|
100
|
+
log "New session: ${NEW_ID}"
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# Export session ID for post_session_report
|
|
105
|
+
export LAST_SESSION_ID="$ACTIVE_SESSION_ID"
|
|
106
|
+
|
|
107
|
+
case "$AGENT_RUNTIME" in
|
|
108
|
+
claude)
|
|
109
|
+
timeout "${AGENT_TIMEOUT}" claude \
|
|
110
|
+
"${SESSION_FLAGS[@]}" \
|
|
111
|
+
--dangerously-skip-permissions \
|
|
112
|
+
-p "$PROMPT" \
|
|
113
|
+
2>&1 | tee -a "$SESSION_LOG"
|
|
114
|
+
;;
|
|
115
|
+
codex)
|
|
116
|
+
local -a MODEL_FLAGS=()
|
|
117
|
+
[[ -n "${AGENT_MODEL:-}" ]] && MODEL_FLAGS=(--model "$AGENT_MODEL")
|
|
118
|
+
timeout "${AGENT_TIMEOUT}" codex \
|
|
119
|
+
"${MODEL_FLAGS[@]}" \
|
|
120
|
+
--approval-mode full-auto \
|
|
121
|
+
--quiet \
|
|
122
|
+
"$PROMPT" \
|
|
123
|
+
2>&1 | tee -a "$SESSION_LOG"
|
|
124
|
+
;;
|
|
125
|
+
aider)
|
|
126
|
+
local -a MODEL_FLAGS=()
|
|
127
|
+
[[ -n "${AGENT_MODEL:-}" ]] && MODEL_FLAGS=(--model "$AGENT_MODEL")
|
|
128
|
+
timeout "${AGENT_TIMEOUT}" aider \
|
|
129
|
+
"${MODEL_FLAGS[@]}" \
|
|
130
|
+
--yes \
|
|
131
|
+
--no-auto-commits \
|
|
132
|
+
--message "$PROMPT" \
|
|
133
|
+
2>&1 | tee -a "$SESSION_LOG"
|
|
134
|
+
;;
|
|
135
|
+
*)
|
|
136
|
+
log "Unknown AGENT_RUNTIME: ${AGENT_RUNTIME}"
|
|
137
|
+
return 1
|
|
138
|
+
;;
|
|
139
|
+
esac
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Handle agent invocation result — common exit code handling
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
handle_agent_result() {
|
|
146
|
+
local EXIT_CODE=$1
|
|
147
|
+
local TASK_LABEL="$2"
|
|
148
|
+
local TIMEOUT_VAL="$3"
|
|
149
|
+
|
|
150
|
+
case $EXIT_CODE in
|
|
151
|
+
0) return 0 ;;
|
|
152
|
+
124)
|
|
153
|
+
log "${TASK_LABEL} timed out after ${TIMEOUT_VAL}s"
|
|
154
|
+
notify "${TASK_LABEL} timed out"
|
|
155
|
+
;;
|
|
156
|
+
127)
|
|
157
|
+
log "FATAL: Agent binary '${AGENT_RUNTIME}' not found"
|
|
158
|
+
notify "${TASK_LABEL} failed — agent binary not found"
|
|
159
|
+
return 1
|
|
160
|
+
;;
|
|
161
|
+
137)
|
|
162
|
+
log "Agent was OOM-killed during ${TASK_LABEL}"
|
|
163
|
+
notify "${TASK_LABEL} failed — out of memory"
|
|
164
|
+
;;
|
|
165
|
+
*)
|
|
166
|
+
log "Agent failed with exit code ${EXIT_CODE} during ${TASK_LABEL}"
|
|
167
|
+
notify "${TASK_LABEL} — agent failed (exit ${EXIT_CODE})"
|
|
168
|
+
;;
|
|
169
|
+
esac
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Post session report as a PR comment
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
post_session_report() {
|
|
176
|
+
local PR_NUM="$1"
|
|
177
|
+
local TASK_ID="$2"
|
|
178
|
+
local DURATION="$3"
|
|
179
|
+
local SESSION_LOG="$4"
|
|
180
|
+
|
|
181
|
+
local COMMIT_COUNT FILES_CHANGED
|
|
182
|
+
COMMIT_COUNT=$(git -C "$REPO" rev-list --count "${BASE_BRANCH}..HEAD" 2>/dev/null || echo "0")
|
|
183
|
+
FILES_CHANGED=$(git -C "$REPO" diff --name-only "${BASE_BRANCH}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
|
|
184
|
+
|
|
185
|
+
local CMD_TOTAL CMD_FAILED
|
|
186
|
+
CMD_TOTAL=$(grep -c "\[${TASK_ID}\]" "${LOG_DIR}/bash-commands.log" 2>/dev/null || echo "0")
|
|
187
|
+
CMD_FAILED=$(grep "\[${TASK_ID}\]" "${LOG_DIR}/bash-commands.log" 2>/dev/null \
|
|
188
|
+
| grep -cv '\[exit:0\]' || echo "0")
|
|
189
|
+
|
|
190
|
+
local LOG_TAIL
|
|
191
|
+
LOG_TAIL=$(tail -50 "$SESSION_LOG" 2>/dev/null || echo "(no log)")
|
|
192
|
+
LOG_TAIL=$(sanitize_secrets "$LOG_TAIL")
|
|
193
|
+
|
|
194
|
+
# Include session-id for review-fix resume
|
|
195
|
+
local SESSION_MARKER=""
|
|
196
|
+
if [[ -n "${LAST_SESSION_ID:-}" ]]; then
|
|
197
|
+
SESSION_MARKER="
|
|
198
|
+
<!-- session-id: ${LAST_SESSION_ID} -->"
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
local COMMENT_ERR
|
|
202
|
+
COMMENT_ERR=$(gh pr comment "$PR_NUM" --repo "$GITHUB_REPO" --body "$(cat <<EOF
|
|
203
|
+
## Session Report
|
|
204
|
+
${SESSION_MARKER}
|
|
205
|
+
|
|
206
|
+
| Metric | Value |
|
|
207
|
+
|---|---|
|
|
208
|
+
| Duration | ${DURATION}m |
|
|
209
|
+
| Commits | ${COMMIT_COUNT} |
|
|
210
|
+
| Files changed | ${FILES_CHANGED} |
|
|
211
|
+
| Bash commands | ${CMD_TOTAL} total, ${CMD_FAILED} failed |
|
|
212
|
+
|
|
213
|
+
<details>
|
|
214
|
+
<summary>Command log (last 50 lines)</summary>
|
|
215
|
+
|
|
216
|
+
\`\`\`
|
|
217
|
+
${LOG_TAIL}
|
|
218
|
+
\`\`\`
|
|
219
|
+
|
|
220
|
+
</details>
|
|
221
|
+
EOF
|
|
222
|
+
)" 2>&1) || log "WARNING: Failed to post session report to PR #${PR_NUM}: ${COMMENT_ERR}"
|
|
223
|
+
}
|