openthrottle 0.1.12 → 0.1.14

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 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,140 @@
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 — blocks until entrypoint exits
111
+ const logStreamDone = sandbox.process.getEntrypointLogs((chunk) => process.stdout.write(chunk), (chunk) => process.stderr.write(chunk));
112
+ await Promise.race([
113
+ logStreamDone,
114
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Task timeout (${taskTimeout}s) exceeded`)), taskTimeout * 1000)),
115
+ ]);
116
+ // Get exit code
117
+ let exitCode = 0;
118
+ try {
119
+ const session = await sandbox.process.getEntrypointSession();
120
+ const commands = session?.commands || [];
121
+ if (commands.length > 0) {
122
+ const last = commands[commands.length - 1];
123
+ if (last.exitCode !== undefined && last.exitCode !== null) {
124
+ exitCode = last.exitCode;
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ // default to 0
130
+ }
131
+ console.log(`[run] Finished (exit code: ${exitCode})`);
132
+ await cleanup();
133
+ process.exit(exitCode);
134
+ }
135
+ catch (err) {
136
+ console.error(`[run] Error: ${err?.message ?? err}`);
137
+ await cleanup();
138
+ process.exit(1);
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openthrottle",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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
- # Multiple triggers fire in parallel (one sandbox per task).
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 (using env vars, not inline expressions)
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: Install Daytona CLI
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=$(yq '.snapshot // ""' .openthrottle.yml)
98
- if [[ -z "$SNAPSHOT" || "$SNAPSHOT" == "null" ]]; then
99
- echo "::error::Missing 'snapshot' key in .openthrottle.yml — cannot create sandbox"
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: Authenticate Daytona CLI
105
- run: daytona login --api-key "$DAYTONA_API_KEY"
106
- env:
107
- DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
99
+ - name: Setup Node.js
100
+ uses: actions/setup-node@v4
101
+ with:
102
+ node-version: '22'
108
103
 
109
- - name: Verify snapshot exists
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
- run: |
114
- # Reactivate if idle >2 weeks, then verify it exists
115
- daytona snapshot activate "$SNAPSHOT" 2>/dev/null || true
116
- if ! daytona snapshot list 2>/dev/null | grep -q "$SNAPSHOT"; then
117
- echo "::error::Snapshot '$SNAPSHOT' not found. Create it first: daytona snapshot create $SNAPSHOT --image <your-image:tag>"
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
- SNAPSHOT: ${{ steps.config.outputs.snapshot }}
133
- WORK_ITEM: ${{ steps.work.outputs.item }}
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