openthrottle 0.1.12 → 0.1.15
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/dist/index.js +6 -0
- package/dist/run.js +146 -0
- package/package.json +2 -1
- package/templates/wake-sandbox.yml +23 -67
package/dist/index.js
CHANGED
|
@@ -281,6 +281,7 @@ const HELP = `Usage: openthrottle <command>
|
|
|
281
281
|
Commands:
|
|
282
282
|
init Set up Open Throttle in your project
|
|
283
283
|
ship <file.md> [--base <branch>] Create a GitHub issue to trigger a sandbox
|
|
284
|
+
run Run sandbox lifecycle (used by GH Actions)
|
|
284
285
|
config [key] [value] View or edit .openthrottle.yml settings
|
|
285
286
|
status Show running, queued, and completed tasks
|
|
286
287
|
logs Show recent GitHub Actions workflow runs
|
|
@@ -310,6 +311,11 @@ async function main() {
|
|
|
310
311
|
configCmd(args.slice(1));
|
|
311
312
|
return;
|
|
312
313
|
}
|
|
314
|
+
if (command === 'run') {
|
|
315
|
+
const { default: run } = await import('./run.js');
|
|
316
|
+
await run();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
313
319
|
preflight();
|
|
314
320
|
switch (command) {
|
|
315
321
|
case 'ship':
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// openthrottle run — Daytona sandbox lifecycle manager.
|
|
3
|
+
//
|
|
4
|
+
// Creates a sandbox, streams entrypoint logs while the orchestrator
|
|
5
|
+
// works inside, then stops and deletes the sandbox when it finishes.
|
|
6
|
+
// =============================================================================
|
|
7
|
+
import { Daytona } from "@daytonaio/sdk";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function requireEnv(name) {
|
|
12
|
+
const value = process.env[name];
|
|
13
|
+
if (!value) {
|
|
14
|
+
console.error(`error: ${name} is required`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
const PASSTHROUGH_KEYS = [
|
|
20
|
+
"ANTHROPIC_API_KEY",
|
|
21
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
22
|
+
"OPENAI_API_KEY",
|
|
23
|
+
"SUPABASE_ACCESS_TOKEN",
|
|
24
|
+
"TELEGRAM_BOT_TOKEN",
|
|
25
|
+
"TELEGRAM_CHAT_ID",
|
|
26
|
+
"FROM_PR",
|
|
27
|
+
];
|
|
28
|
+
const SKIP_PATTERN = /^(GITHUB|RUNNER|ACTIONS|INPUT|CI|HOME|PATH|SHELL|USER|TERM|PWD|NODE|SNAPSHOT|DAYTONA|LANG|LC_|DOTNET|JAVA|ANDROID|CHROME|DEBIAN|HOMEBREW|GOROOT|PIPX|CONDA|TASK_TIMEOUT)/;
|
|
29
|
+
function buildSandboxEnv(githubToken, githubRepo, workItem, taskType) {
|
|
30
|
+
const env = {
|
|
31
|
+
GITHUB_TOKEN: githubToken,
|
|
32
|
+
GITHUB_REPO: githubRepo,
|
|
33
|
+
WORK_ITEM: workItem,
|
|
34
|
+
TASK_TYPE: taskType,
|
|
35
|
+
};
|
|
36
|
+
for (const key of PASSTHROUGH_KEYS) {
|
|
37
|
+
const value = process.env[key];
|
|
38
|
+
if (value)
|
|
39
|
+
env[key] = value;
|
|
40
|
+
}
|
|
41
|
+
// Project-specific secrets (scaffolder-injected @@ENV_SECRETS@@)
|
|
42
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
43
|
+
if (!value || key in env)
|
|
44
|
+
continue;
|
|
45
|
+
if (!/^[A-Z][A-Z0-9_]+$/.test(key))
|
|
46
|
+
continue;
|
|
47
|
+
if (SKIP_PATTERN.test(key))
|
|
48
|
+
continue;
|
|
49
|
+
env[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Main (exported for use by CLI index.ts)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
export default async function run() {
|
|
57
|
+
const apiKey = requireEnv("DAYTONA_API_KEY");
|
|
58
|
+
const snapshot = requireEnv("SNAPSHOT");
|
|
59
|
+
const workItem = requireEnv("WORK_ITEM");
|
|
60
|
+
const taskType = requireEnv("TASK_TYPE");
|
|
61
|
+
const githubRepo = process.env.GITHUB_REPO || process.env["github.repository"] || "";
|
|
62
|
+
const githubToken = process.env.GH_PAT || process.env.GITHUB_TOKEN || "";
|
|
63
|
+
const runId = process.env.GITHUB_RUN_ID || String(Date.now());
|
|
64
|
+
const taskTimeout = parseInt(process.env.TASK_TIMEOUT || "7200", 10);
|
|
65
|
+
const daytona = new Daytona({ apiKey });
|
|
66
|
+
let sandbox;
|
|
67
|
+
const sandboxName = `ot-${taskType}-${workItem}-${runId}`;
|
|
68
|
+
const cleanup = async (signal) => {
|
|
69
|
+
if (signal)
|
|
70
|
+
console.log(`\n[run] Received ${signal}`);
|
|
71
|
+
if (!sandbox)
|
|
72
|
+
return;
|
|
73
|
+
const s = sandbox;
|
|
74
|
+
sandbox = undefined;
|
|
75
|
+
try {
|
|
76
|
+
console.log("[run] Stopping sandbox...");
|
|
77
|
+
await s.stop();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// may already be stopped
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
console.log("[run] Deleting sandbox...");
|
|
84
|
+
await s.delete();
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
console.error("[run] Delete failed:", e);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
process.once("SIGINT", () => cleanup("SIGINT").then(() => process.exit(130)));
|
|
91
|
+
process.once("SIGTERM", () => cleanup("SIGTERM").then(() => process.exit(143)));
|
|
92
|
+
try {
|
|
93
|
+
console.log(`[run] Creating sandbox: ${sandboxName}`);
|
|
94
|
+
console.log(`[run] Snapshot: ${snapshot}`);
|
|
95
|
+
console.log(`[run] Task: ${taskType} #${workItem}`);
|
|
96
|
+
sandbox = await daytona.create({
|
|
97
|
+
snapshot,
|
|
98
|
+
name: sandboxName,
|
|
99
|
+
envVars: buildSandboxEnv(githubToken, githubRepo, workItem, taskType),
|
|
100
|
+
labels: {
|
|
101
|
+
project: githubRepo.split("/").pop() || "unknown",
|
|
102
|
+
task_type: taskType,
|
|
103
|
+
issue: workItem,
|
|
104
|
+
},
|
|
105
|
+
autoStopInterval: Math.ceil(taskTimeout / 60) + 10,
|
|
106
|
+
autoDeleteInterval: Math.ceil(taskTimeout / 60) + 30,
|
|
107
|
+
});
|
|
108
|
+
console.log(`[run] Sandbox created: ${sandboxName}`);
|
|
109
|
+
console.log(`[run] Streaming logs (timeout: ${taskTimeout}s)...`);
|
|
110
|
+
// Stream entrypoint logs in background
|
|
111
|
+
sandbox.process.getEntrypointLogs((chunk) => process.stdout.write(chunk), (chunk) => process.stderr.write(chunk)).catch(() => { }); // stream may error when sandbox stops — that's fine
|
|
112
|
+
// Poll for entrypoint completion. getEntrypointLogs() may not resolve
|
|
113
|
+
// when the entrypoint exits because Daytona keeps the container alive.
|
|
114
|
+
const POLL_MS = 5_000;
|
|
115
|
+
let exitCode = 0;
|
|
116
|
+
const deadline = Date.now() + taskTimeout * 1000;
|
|
117
|
+
while (Date.now() < deadline) {
|
|
118
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
119
|
+
try {
|
|
120
|
+
const session = await sandbox.process.getEntrypointSession();
|
|
121
|
+
const commands = session?.commands || [];
|
|
122
|
+
if (commands.length > 0) {
|
|
123
|
+
const last = commands[commands.length - 1];
|
|
124
|
+
if (last.exitCode !== undefined && last.exitCode !== null) {
|
|
125
|
+
exitCode = last.exitCode;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// sandbox may not be ready yet — keep polling
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (Date.now() >= deadline) {
|
|
135
|
+
throw new Error(`Task timeout (${taskTimeout}s) exceeded`);
|
|
136
|
+
}
|
|
137
|
+
console.log(`[run] Finished (exit code: ${exitCode})`);
|
|
138
|
+
await cleanup();
|
|
139
|
+
process.exit(exitCode);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error(`[run] Error: ${err?.message ?? err}`);
|
|
143
|
+
await cleanup();
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openthrottle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "CLI for Open Throttle — ship prompts to Daytona sandboxes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"prepublishOnly": "npm run build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@daytonaio/sdk": "^0.153.0",
|
|
20
21
|
"prompts": "^2.4.2",
|
|
21
22
|
"yaml": "^2.4.0"
|
|
22
23
|
},
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
# - PR review with changes_requested → review-fix sandbox
|
|
7
7
|
#
|
|
8
8
|
# Each trigger creates a fresh ephemeral sandbox — no polling, no long-lived state.
|
|
9
|
-
#
|
|
9
|
+
# The GH Actions runner stays alive to stream logs and clean up the sandbox.
|
|
10
10
|
#
|
|
11
11
|
# Required repository secrets:
|
|
12
12
|
# DAYTONA_API_KEY — API key from daytona.io
|
|
13
|
+
# GH_PAT — GitHub PAT for sandbox git operations
|
|
13
14
|
# ANTHROPIC_API_KEY — (option a) or
|
|
14
15
|
# CLAUDE_CODE_OAUTH_TOKEN — (option b) for Claude Code auth
|
|
15
16
|
# TELEGRAM_BOT_TOKEN — optional, for notifications
|
|
16
17
|
# TELEGRAM_CHAT_ID — optional, for notifications
|
|
17
|
-
# SUPABASE_ACCESS_TOKEN — optional, for Supabase MCP
|
|
18
18
|
|
|
19
19
|
name: Wake Sandbox
|
|
20
20
|
|
|
@@ -39,6 +39,7 @@ jobs:
|
|
|
39
39
|
run-task:
|
|
40
40
|
if: ${{ contains(fromJSON('["prd-queued","bug-queued","needs-review","needs-investigation","triage-queued"]'), github.event.label.name) || (github.event.review.state == 'changes_requested') }}
|
|
41
41
|
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 150
|
|
42
43
|
steps:
|
|
43
44
|
- uses: actions/checkout@v6
|
|
44
45
|
|
|
@@ -62,7 +63,7 @@ jobs:
|
|
|
62
63
|
fi
|
|
63
64
|
echo "item=$WORK_ITEM" >> "$GITHUB_OUTPUT"
|
|
64
65
|
|
|
65
|
-
# Determine task type
|
|
66
|
+
# Determine task type
|
|
66
67
|
TASK_TYPE="prd"
|
|
67
68
|
if [[ "$EVENT_LABEL" == "bug-queued" ]]; then
|
|
68
69
|
TASK_TYPE="bug"
|
|
@@ -77,89 +78,44 @@ jobs:
|
|
|
77
78
|
fi
|
|
78
79
|
echo "task_type=$TASK_TYPE" >> "$GITHUB_OUTPUT"
|
|
79
80
|
|
|
80
|
-
# For review-fix tasks, pass the PR number so the sandbox can
|
|
81
|
-
# use `claude --from-pr` to resume conversation context
|
|
82
81
|
FROM_PR=""
|
|
83
82
|
if [[ "$TASK_TYPE" == "review-fix" ]]; then
|
|
84
83
|
FROM_PR="${EVENT_PR_NUM}"
|
|
85
84
|
fi
|
|
86
85
|
echo "from_pr=$FROM_PR" >> "$GITHUB_OUTPUT"
|
|
87
86
|
|
|
88
|
-
- name:
|
|
89
|
-
run: curl -Lo daytona "https://download.daytona.io/cli/latest/daytona-linux-amd64" && sudo chmod +x daytona && sudo mv daytona /usr/local/bin/
|
|
90
|
-
|
|
91
|
-
- name: Install yq
|
|
92
|
-
run: sudo snap install yq
|
|
93
|
-
|
|
94
|
-
- name: Validate config
|
|
87
|
+
- name: Read snapshot from config
|
|
95
88
|
id: config
|
|
96
89
|
run: |
|
|
97
|
-
SNAPSHOT=$(
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
SNAPSHOT=$(grep '^snapshot:' .openthrottle.yml 2>/dev/null | awk '{print $2}' || echo "")
|
|
91
|
+
TASK_TIMEOUT=$(grep 'task_timeout:' .openthrottle.yml 2>/dev/null | awk '{print $2}' || echo "7200")
|
|
92
|
+
if [[ -z "$SNAPSHOT" ]]; then
|
|
93
|
+
echo "::error::Missing 'snapshot' in .openthrottle.yml"
|
|
100
94
|
exit 1
|
|
101
95
|
fi
|
|
102
96
|
echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT"
|
|
97
|
+
echo "task_timeout=$TASK_TIMEOUT" >> "$GITHUB_OUTPUT"
|
|
103
98
|
|
|
104
|
-
- name:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
99
|
+
- name: Setup Node.js
|
|
100
|
+
uses: actions/setup-node@v4
|
|
101
|
+
with:
|
|
102
|
+
node-version: '22'
|
|
108
103
|
|
|
109
|
-
- name:
|
|
104
|
+
- name: Run sandbox lifecycle
|
|
110
105
|
env:
|
|
111
106
|
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
|
112
107
|
SNAPSHOT: ${{ steps.config.outputs.snapshot }}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
echo "Available snapshots:"
|
|
119
|
-
daytona snapshot list 2>/dev/null || echo " (could not list snapshots)"
|
|
120
|
-
exit 1
|
|
121
|
-
fi
|
|
122
|
-
echo "Snapshot verified: $SNAPSHOT"
|
|
123
|
-
|
|
124
|
-
- name: Create and run sandbox
|
|
125
|
-
env:
|
|
126
|
-
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
|
127
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
108
|
+
WORK_ITEM: ${{ steps.work.outputs.item }}
|
|
109
|
+
TASK_TYPE: ${{ steps.work.outputs.task_type }}
|
|
110
|
+
TASK_TIMEOUT: ${{ steps.config.outputs.task_timeout }}
|
|
111
|
+
GH_PAT: ${{ secrets.GH_PAT }}
|
|
112
|
+
GITHUB_REPO: ${{ github.repository }}
|
|
128
113
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
129
114
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
130
115
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
131
116
|
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
TASK_TYPE: ${{ steps.work.outputs.task_type }}
|
|
117
|
+
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
|
118
|
+
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
|
135
119
|
FROM_PR: ${{ steps.work.outputs.from_pr }}
|
|
136
120
|
# @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here
|
|
137
|
-
run:
|
|
138
|
-
SANDBOX_NAME="ot-${TASK_TYPE}-${WORK_ITEM}-${GITHUB_RUN_ID}"
|
|
139
|
-
|
|
140
|
-
# Create ephemeral sandbox
|
|
141
|
-
daytona create \
|
|
142
|
-
--snapshot "$SNAPSHOT" \
|
|
143
|
-
--name "$SANDBOX_NAME" \
|
|
144
|
-
--auto-stop 180 \
|
|
145
|
-
--label "project=${{ github.event.repository.name }}" \
|
|
146
|
-
--label "task_type=${TASK_TYPE}" \
|
|
147
|
-
--label "issue=${WORK_ITEM}" \
|
|
148
|
-
--env "GITHUB_TOKEN=${{ secrets.GH_PAT }}" \
|
|
149
|
-
--env "GITHUB_REPO=${{ github.repository }}" \
|
|
150
|
-
--env "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}" \
|
|
151
|
-
--env "CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}" \
|
|
152
|
-
--env "OPENAI_API_KEY=${OPENAI_API_KEY}" \
|
|
153
|
-
--env "SUPABASE_ACCESS_TOKEN=${SUPABASE_ACCESS_TOKEN}" \
|
|
154
|
-
--env "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" \
|
|
155
|
-
--env "TELEGRAM_CHAT_ID=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
|
156
|
-
--env "WORK_ITEM=${WORK_ITEM}" \
|
|
157
|
-
--env "TASK_TYPE=${TASK_TYPE}" \
|
|
158
|
-
--env "FROM_PR=${FROM_PR}" \
|
|
159
|
-
# @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here
|
|
160
|
-
|
|
161
|
-
echo "Sandbox created: $SANDBOX_NAME"
|
|
162
|
-
echo "Task: ${TASK_TYPE} #${WORK_ITEM}"
|
|
163
|
-
if [[ -n "$FROM_PR" ]]; then
|
|
164
|
-
echo "Review-fix mode: sandbox will use 'claude --from-pr $FROM_PR' to resume PR context"
|
|
165
|
-
fi
|
|
121
|
+
run: npx openthrottle@latest run
|