openthrottle 0.1.11 → 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.
@@ -0,0 +1,125 @@
1
+ // =============================================================================
2
+ // openthrottle config — View and edit .openthrottle.yml settings.
3
+ //
4
+ // Usage:
5
+ // openthrottle config Show current config
6
+ // openthrottle config <key> <value> Set a value (dot-notation)
7
+ //
8
+ // Examples:
9
+ // openthrottle config phases.plan.model opus
10
+ // openthrottle config phases.build.timeout 3600
11
+ // openthrottle config limits.task_timeout 7200
12
+ // openthrottle config review.enabled false
13
+ // =============================================================================
14
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { parse, stringify } from 'yaml';
17
+ const CONFIG_FILE = '.openthrottle.yml';
18
+ function configPath() {
19
+ return join(process.cwd(), CONFIG_FILE);
20
+ }
21
+ function loadYaml() {
22
+ const path = configPath();
23
+ if (!existsSync(path)) {
24
+ console.error(`No ${CONFIG_FILE} found. Run "openthrottle init" first.`);
25
+ process.exit(1);
26
+ }
27
+ return parse(readFileSync(path, 'utf8'));
28
+ }
29
+ function saveYaml(doc) {
30
+ const header = [
31
+ '# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
32
+ '# Edit directly or use: openthrottle config <key> <value>',
33
+ '',
34
+ ].join('\n');
35
+ writeFileSync(configPath(), header + stringify(doc));
36
+ }
37
+ /** Get a nested value using dot notation: "phases.plan.model" */
38
+ function getDeep(obj, path) {
39
+ const parts = path.split('.');
40
+ let cur = obj;
41
+ for (const part of parts) {
42
+ if (cur == null || typeof cur !== 'object')
43
+ return undefined;
44
+ cur = cur[part];
45
+ }
46
+ return cur;
47
+ }
48
+ /** Set a nested value using dot notation, creating intermediate objects. */
49
+ function setDeep(obj, path, value) {
50
+ const parts = path.split('.');
51
+ let cur = obj;
52
+ for (let i = 0; i < parts.length - 1; i++) {
53
+ if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') {
54
+ cur[parts[i]] = {};
55
+ }
56
+ cur = cur[parts[i]];
57
+ }
58
+ cur[parts[parts.length - 1]] = value;
59
+ }
60
+ /** Coerce string values to appropriate types. */
61
+ function coerce(value) {
62
+ if (value === 'true')
63
+ return true;
64
+ if (value === 'false')
65
+ return false;
66
+ const num = Number(value);
67
+ if (!isNaN(num) && value.trim() !== '')
68
+ return num;
69
+ return value;
70
+ }
71
+ function printConfig(doc, prefix = '', indent = 0) {
72
+ const pad = ' '.repeat(indent);
73
+ for (const [key, val] of Object.entries(doc)) {
74
+ const fullKey = prefix ? `${prefix}.${key}` : key;
75
+ if (val != null && typeof val === 'object' && !Array.isArray(val)) {
76
+ console.log(`${pad}${key}:`);
77
+ printConfig(val, fullKey, indent + 1);
78
+ }
79
+ else if (Array.isArray(val)) {
80
+ console.log(`${pad}${key}:`);
81
+ for (const item of val) {
82
+ if (typeof item === 'object') {
83
+ console.log(`${pad} -`);
84
+ printConfig(item, fullKey, indent + 2);
85
+ }
86
+ else {
87
+ console.log(`${pad} - ${item}`);
88
+ }
89
+ }
90
+ }
91
+ else {
92
+ console.log(`${pad}${key}: ${val}`);
93
+ }
94
+ }
95
+ }
96
+ export default function configCmd(args) {
97
+ const doc = loadYaml();
98
+ // No args → show config
99
+ if (args.length === 0) {
100
+ printConfig(doc);
101
+ return;
102
+ }
103
+ // One arg → show specific key
104
+ if (args.length === 1) {
105
+ const val = getDeep(doc, args[0]);
106
+ if (val === undefined) {
107
+ console.error(`Key not found: ${args[0]}`);
108
+ process.exit(1);
109
+ }
110
+ if (typeof val === 'object') {
111
+ printConfig(val);
112
+ }
113
+ else {
114
+ console.log(val);
115
+ }
116
+ return;
117
+ }
118
+ // Two args → set key
119
+ const [key, ...rest] = args;
120
+ const rawValue = rest.join(' ');
121
+ const value = coerce(rawValue);
122
+ setDeep(doc, key, value);
123
+ saveYaml(doc);
124
+ console.log(` ${key}: ${value}`);
125
+ }
package/dist/index.js CHANGED
@@ -127,6 +127,9 @@ function cmdShip(args) {
127
127
  'prd-queued', 'prd-running', 'prd-complete', 'prd-failed',
128
128
  'needs-review', 'reviewing',
129
129
  'bug-queued', 'bug-running', 'bug-complete', 'bug-failed',
130
+ 'bug-confirmed', 'bug-unconfirmed',
131
+ 'triage-queued', 'triage-running', 'triage-complete',
132
+ 'skip-plan',
130
133
  ];
131
134
  for (const label of labels) {
132
135
  try {
@@ -278,6 +281,8 @@ const HELP = `Usage: openthrottle <command>
278
281
  Commands:
279
282
  init Set up Open Throttle in your project
280
283
  ship <file.md> [--base <branch>] Create a GitHub issue to trigger a sandbox
284
+ run Run sandbox lifecycle (used by GH Actions)
285
+ config [key] [value] View or edit .openthrottle.yml settings
281
286
  status Show running, queued, and completed tasks
282
287
  logs Show recent GitHub Actions workflow runs
283
288
 
@@ -301,6 +306,16 @@ async function main() {
301
306
  await init();
302
307
  return;
303
308
  }
309
+ if (command === 'config') {
310
+ const { default: configCmd } = await import('./config-cmd.js');
311
+ configCmd(args.slice(1));
312
+ return;
313
+ }
314
+ if (command === 'run') {
315
+ const { default: run } = await import('./run.js');
316
+ await run();
317
+ return;
318
+ }
304
319
  preflight();
305
320
  switch (command) {
306
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.11",
3
+ "version": "0.1.14",
4
4
  "description": "CLI for Open Throttle — ship prompts to Daytona sandboxes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,9 +12,12 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "build": "tsc",
15
+ "test": "echo 'No tests'",
16
+ "lint": "echo 'No linter configured'",
15
17
  "prepublishOnly": "npm run build"
16
18
  },
17
19
  "dependencies": {
20
+ "@daytonaio/sdk": "^0.153.0",
18
21
  "prompts": "^2.4.2",
19
22
  "yaml": "^2.4.0"
20
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
 
@@ -37,8 +37,9 @@ permissions:
37
37
 
38
38
  jobs:
39
39
  run-task:
40
- if: ${{ contains(fromJSON('["prd-queued","bug-queued","needs-review","needs-investigation"]'), github.event.label.name) || (github.event.review.state == 'changes_requested') }}
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"
@@ -70,94 +71,51 @@ jobs:
70
71
  TASK_TYPE="review"
71
72
  elif [[ "$EVENT_LABEL" == "needs-investigation" ]]; then
72
73
  TASK_TYPE="investigation"
74
+ elif [[ "$EVENT_LABEL" == "triage-queued" ]]; then
75
+ TASK_TYPE="triage"
73
76
  elif [[ "$EVENT_REVIEW_STATE" == "changes_requested" ]]; then
74
77
  TASK_TYPE="review-fix"
75
78
  fi
76
79
  echo "task_type=$TASK_TYPE" >> "$GITHUB_OUTPUT"
77
80
 
78
- # For review-fix tasks, pass the PR number so the sandbox can
79
- # use `claude --from-pr` to resume conversation context
80
81
  FROM_PR=""
81
82
  if [[ "$TASK_TYPE" == "review-fix" ]]; then
82
83
  FROM_PR="${EVENT_PR_NUM}"
83
84
  fi
84
85
  echo "from_pr=$FROM_PR" >> "$GITHUB_OUTPUT"
85
86
 
86
- - name: Install Daytona CLI
87
- run: curl -Lo daytona "https://download.daytona.io/cli/latest/daytona-linux-amd64" && sudo chmod +x daytona && sudo mv daytona /usr/local/bin/
88
-
89
- - name: Install yq
90
- run: sudo snap install yq
91
-
92
- - name: Validate config
87
+ - name: Read snapshot from config
93
88
  id: config
94
89
  run: |
95
- SNAPSHOT=$(yq '.snapshot // ""' .openthrottle.yml)
96
- if [[ -z "$SNAPSHOT" || "$SNAPSHOT" == "null" ]]; then
97
- 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"
98
94
  exit 1
99
95
  fi
100
96
  echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT"
97
+ echo "task_timeout=$TASK_TIMEOUT" >> "$GITHUB_OUTPUT"
101
98
 
102
- - name: Authenticate Daytona CLI
103
- run: daytona login --api-key "$DAYTONA_API_KEY"
104
- env:
105
- DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
99
+ - name: Setup Node.js
100
+ uses: actions/setup-node@v4
101
+ with:
102
+ node-version: '22'
106
103
 
107
- - name: Verify snapshot exists
104
+ - name: Run sandbox lifecycle
108
105
  env:
109
106
  DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
110
107
  SNAPSHOT: ${{ steps.config.outputs.snapshot }}
111
- run: |
112
- # Reactivate if idle >2 weeks, then verify it exists
113
- daytona snapshot activate "$SNAPSHOT" 2>/dev/null || true
114
- if ! daytona snapshot list 2>/dev/null | grep -q "$SNAPSHOT"; then
115
- echo "::error::Snapshot '$SNAPSHOT' not found. Create it first: daytona snapshot create $SNAPSHOT --image <your-image:tag>"
116
- echo "Available snapshots:"
117
- daytona snapshot list 2>/dev/null || echo " (could not list snapshots)"
118
- exit 1
119
- fi
120
- echo "Snapshot verified: $SNAPSHOT"
121
-
122
- - name: Create and run sandbox
123
- env:
124
- DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
125
- 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 }}
126
113
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
127
114
  CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
128
115
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
129
116
  SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
130
- SNAPSHOT: ${{ steps.config.outputs.snapshot }}
131
- WORK_ITEM: ${{ steps.work.outputs.item }}
132
- TASK_TYPE: ${{ steps.work.outputs.task_type }}
117
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
118
+ TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
133
119
  FROM_PR: ${{ steps.work.outputs.from_pr }}
134
120
  # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here
135
- run: |
136
- SANDBOX_NAME="ot-${TASK_TYPE}-${WORK_ITEM}-${GITHUB_RUN_ID}"
137
-
138
- # Create ephemeral sandbox
139
- daytona create \
140
- --snapshot "$SNAPSHOT" \
141
- --name "$SANDBOX_NAME" \
142
- --auto-stop 180 \
143
- --label "project=${{ github.event.repository.name }}" \
144
- --label "task_type=${TASK_TYPE}" \
145
- --label "issue=${WORK_ITEM}" \
146
- --env "GITHUB_TOKEN=${{ secrets.GH_PAT }}" \
147
- --env "GITHUB_REPO=${{ github.repository }}" \
148
- --env "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}" \
149
- --env "CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}" \
150
- --env "OPENAI_API_KEY=${OPENAI_API_KEY}" \
151
- --env "SUPABASE_ACCESS_TOKEN=${SUPABASE_ACCESS_TOKEN}" \
152
- --env "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" \
153
- --env "TELEGRAM_CHAT_ID=${{ secrets.TELEGRAM_CHAT_ID }}" \
154
- --env "WORK_ITEM=${WORK_ITEM}" \
155
- --env "TASK_TYPE=${TASK_TYPE}" \
156
- --env "FROM_PR=${FROM_PR}" \
157
- # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here
158
-
159
- echo "Sandbox created: $SANDBOX_NAME"
160
- echo "Task: ${TASK_TYPE} #${WORK_ITEM}"
161
- if [[ -n "$FROM_PR" ]]; then
162
- echo "Review-fix mode: sandbox will use 'claude --from-pr $FROM_PR' to resume PR context"
163
- fi
121
+ run: npx openthrottle@latest run