@openhands/extensions 0.5.0 → 0.6.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.
Files changed (132) hide show
  1. package/.github/workflows/pypi-publish.yml +104 -0
  2. package/.github/workflows/tests.yml +6 -0
  3. package/.release-please-manifest.json +1 -1
  4. package/AGENTS.md +1 -1
  5. package/README.md +31 -3
  6. package/integrations/README.md +17 -7
  7. package/integrations/catalog/airtable.json +40 -10
  8. package/integrations/catalog/apify.json +0 -2
  9. package/integrations/catalog/asana.json +38 -0
  10. package/integrations/catalog/atlassian.json +5 -8
  11. package/integrations/catalog/bitbucket.json +39 -0
  12. package/integrations/catalog/box.json +39 -0
  13. package/integrations/catalog/brave-search.json +1 -2
  14. package/integrations/catalog/browser-mcp.json +0 -3
  15. package/integrations/catalog/canva.json +35 -0
  16. package/integrations/catalog/clickhouse.json +1 -2
  17. package/integrations/catalog/clickup.json +36 -0
  18. package/integrations/catalog/cloudflare-bindings.json +5 -8
  19. package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
  20. package/integrations/catalog/cloudflare-builds.json +6 -9
  21. package/integrations/catalog/cloudflare-docs.json +4 -7
  22. package/integrations/catalog/cloudflare-observability.json +5 -8
  23. package/integrations/catalog/confluence.json +38 -0
  24. package/integrations/catalog/datadog.json +34 -0
  25. package/integrations/catalog/deepwiki.json +3 -7
  26. package/integrations/catalog/discord.json +39 -0
  27. package/integrations/catalog/dropbox.json +38 -0
  28. package/integrations/catalog/elevenlabs.json +27 -28
  29. package/integrations/catalog/everything.json +0 -2
  30. package/integrations/catalog/exa.json +0 -2
  31. package/integrations/catalog/fetch.json +0 -2
  32. package/integrations/catalog/figma.json +41 -9
  33. package/integrations/catalog/filesystem.json +0 -3
  34. package/integrations/catalog/firecrawl.json +0 -2
  35. package/integrations/catalog/freshdesk.json +38 -0
  36. package/integrations/catalog/git.json +0 -3
  37. package/integrations/catalog/github.json +43 -10
  38. package/integrations/catalog/gitlab.json +38 -0
  39. package/integrations/catalog/gmail.json +38 -0
  40. package/integrations/catalog/google-calendar.json +38 -0
  41. package/integrations/catalog/google-docs.json +38 -0
  42. package/integrations/catalog/google-drive.json +38 -0
  43. package/integrations/catalog/google-sheets.json +38 -0
  44. package/integrations/catalog/hubspot.json +34 -0
  45. package/integrations/catalog/huggingface.json +1 -2
  46. package/integrations/catalog/intercom.json +39 -0
  47. package/integrations/catalog/jira.json +38 -0
  48. package/integrations/catalog/kagi.json +2 -3
  49. package/integrations/catalog/linear.json +47 -16
  50. package/integrations/catalog/mailchimp.json +38 -0
  51. package/integrations/catalog/memory.json +0 -2
  52. package/integrations/catalog/microsoft-outlook.json +38 -0
  53. package/integrations/catalog/microsoft-teams.json +38 -0
  54. package/integrations/catalog/miro.json +38 -0
  55. package/integrations/catalog/monday.json +38 -0
  56. package/integrations/catalog/mongodb.json +1 -2
  57. package/integrations/catalog/neon.json +0 -2
  58. package/integrations/catalog/netlify.json +38 -0
  59. package/integrations/catalog/notion.json +37 -10
  60. package/integrations/catalog/obsidian.json +1 -3
  61. package/integrations/catalog/okta.json +38 -0
  62. package/integrations/catalog/onedrive.json +38 -0
  63. package/integrations/catalog/ordinal.json +33 -0
  64. package/integrations/catalog/paypal.json +5 -8
  65. package/integrations/catalog/pipedrive.json +39 -0
  66. package/integrations/catalog/plaid.json +31 -0
  67. package/integrations/catalog/playwright.json +0 -2
  68. package/integrations/catalog/posthog.json +38 -0
  69. package/integrations/catalog/quickbooks.json +38 -0
  70. package/integrations/catalog/redis.json +1 -3
  71. package/integrations/catalog/resend.json +1 -2
  72. package/integrations/catalog/salesforce.json +38 -0
  73. package/integrations/catalog/sentry.json +52 -14
  74. package/integrations/catalog/sequential-thinking.json +0 -2
  75. package/integrations/catalog/servicenow.json +39 -0
  76. package/integrations/catalog/sharepoint.json +38 -0
  77. package/integrations/catalog/shopify.json +38 -0
  78. package/integrations/catalog/slack.json +49 -10
  79. package/integrations/catalog/stripe.json +44 -14
  80. package/integrations/catalog/supabase.json +36 -9
  81. package/integrations/catalog/tavily.json +0 -2
  82. package/integrations/catalog/time.json +0 -2
  83. package/integrations/catalog/trello.json +31 -0
  84. package/integrations/catalog/vercel.json +38 -0
  85. package/integrations/catalog/webflow.json +34 -0
  86. package/integrations/catalog/xero.json +38 -0
  87. package/integrations/catalog/zendesk.json +38 -0
  88. package/integrations/catalog/zoom.json +38 -0
  89. package/integrations/catalog-index.js +171 -0
  90. package/integrations/index.d.ts +24 -60
  91. package/integrations/index.js +29 -207
  92. package/marketplaces/openhands-extensions.json +14 -1
  93. package/package.json +4 -7
  94. package/plugins/cobol-modernization/README.md +3 -3
  95. package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
  96. package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
  97. package/plugins/migration-scoring/README.md +1 -1
  98. package/pyproject.toml +17 -3
  99. package/python/openhands_extensions/__init__.py +24 -0
  100. package/python/openhands_extensions/_version.py +24 -0
  101. package/python/openhands_extensions/integrations.py +92 -0
  102. package/release-please-config.json +2 -1
  103. package/scripts/build-integration-catalog.mjs +45 -0
  104. package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
  105. package/skills/agent-canvas-environment/README.md +5 -0
  106. package/skills/agent-canvas-environment/SKILL.md +141 -0
  107. package/skills/github-pr-reviewer/references/state-schema.md +18 -4
  108. package/skills/github-pr-reviewer/scripts/main.py +66 -12
  109. package/skills/github-repo-monitor/references/state-schema.md +11 -4
  110. package/skills/github-repo-monitor/scripts/main.py +67 -19
  111. package/skills/github-repo-monitor/tests/test_main.py +67 -8
  112. package/skills/index.js +19 -5
  113. package/skills/openhands-api/README.md +5 -1
  114. package/skills/openhands-api/SKILL.md +74 -5
  115. package/skills/openhands-automation/SKILL.md +3 -1
  116. package/skills/openhands-automation/references/custom-automation.md +166 -0
  117. package/skills/openhands-sdk/SKILL.md +8 -2
  118. package/skills/slack-channel-monitor/README.md +3 -3
  119. package/skills/slack-channel-monitor/SKILL.md +3 -2
  120. package/skills/slack-channel-monitor/references/state-schema.md +11 -4
  121. package/skills/slack-channel-monitor/scripts/main.py +111 -41
  122. package/tests/test_catalogs.py +21 -3
  123. package/tests/test_integration_catalog_in_sync.py +204 -0
  124. package/tests/test_slack_channel_monitor.py +142 -0
  125. package/tests/test_version_alignment.py +64 -0
  126. package/integrations/logos.d.ts +0 -5
  127. package/integrations/logos.js +0 -94
  128. package/integrations/oauth-provider-catalog.js +0 -553
  129. package/integrations/oauth-provider-registration-defaults.js +0 -610
  130. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
  131. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
  132. /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
@@ -0,0 +1,45 @@
1
+ import { readdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const root = process.cwd();
5
+ const catalogDir = path.join(root, "integrations", "catalog");
6
+ const outputPath = path.join(root, "integrations", "catalog-index.js");
7
+
8
+ const files = (await readdir(catalogDir))
9
+ .filter((file) => file.endsWith(".json"))
10
+ .sort((left, right) => left.localeCompare(right));
11
+
12
+ const integrations = await Promise.all(
13
+ files.map(async (file) => ({
14
+ file,
15
+ entry: JSON.parse(await readFile(path.join(catalogDir, file), "utf8")),
16
+ })),
17
+ );
18
+
19
+ integrations.sort((left, right) => {
20
+ const rank = (right.entry.popularityRank ?? -1) - (left.entry.popularityRank ?? -1);
21
+ return rank || left.entry.id.localeCompare(right.entry.id);
22
+ });
23
+
24
+ const indexedIntegrations = integrations.map((integration, index) => ({
25
+ ...integration,
26
+ importName: `entry${index}`,
27
+ }));
28
+
29
+ const imports = indexedIntegrations
30
+ .map(({ file, importName }) => `import ${importName} from "./catalog/${file}" with { type: "json" };`)
31
+ .join("\n");
32
+ const entries = indexedIntegrations.map(({ importName }) => ` ${importName},`).join("\n");
33
+
34
+ const header = `// This file is auto-generated by scripts/build-integration-catalog.mjs.
35
+ // Do not edit it manually. To update it after changing integrations/catalog/*.json,
36
+ // run: npm run build:integrations
37
+
38
+ `;
39
+ const body = `${header}${imports}
40
+
41
+ export const INTEGRATION_CATALOG_ENTRIES = [
42
+ ${entries}
43
+ ];
44
+ `;
45
+ await writeFile(outputPath, body);
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "agent-canvas-environment",
3
+ "version": "1.0.0",
4
+ "description": "Work effectively inside a local Agent Canvas environment, including local agent-server auth, frontend/backend port discovery, safe workspace hygiene, and local conversation delegation.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "agent-canvas",
14
+ "openhands",
15
+ "local",
16
+ "conversation",
17
+ "delegation"
18
+ ]
19
+ }
@@ -0,0 +1,5 @@
1
+ # agent-canvas-environment
2
+
3
+ Guidance for working inside a local Agent Canvas environment, including local agent-server auth, safe workspace hygiene, and delegation to new local conversations.
4
+
5
+ See: [SKILL.md](./SKILL.md)
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: agent-canvas-environment
3
+ description: Work effectively inside a local Agent Canvas environment, including local agent-server auth, frontend/backend port discovery, safe workspace hygiene, and delegating work to a new local conversation through POST /api/conversations.
4
+ triggers:
5
+ - agent canvas
6
+ - agent-canvas
7
+ - local conversation
8
+ - delegate local conversation
9
+ - session api key
10
+ - X-Session-API-Key
11
+ - localhost:8001
12
+ ---
13
+
14
+ # Agent Canvas Environment
15
+
16
+ Use this skill when running inside or alongside a local Agent Canvas stack, especially when the user asks to inspect the local backend, create or monitor local conversations, or delegate work to another local conversation.
17
+
18
+ ## Core rules
19
+
20
+ - Treat the local Agent Canvas backend as an agent-server API, usually `http://localhost:8001`.
21
+ - Treat the local UI as a separate frontend, usually `http://localhost:8000`.
22
+ - Do not print session API keys. Pass them directly in `X-Session-API-Key`.
23
+ - Trust any runtime-services block or explicit user-provided host over default ports.
24
+ - Before mutating a repository, check `git status -sb`. If a worktree has unrelated changes, use a separate worktree or clone.
25
+ - When delegating, write a self-contained prompt. The new conversation does not inherit the current chat context.
26
+
27
+ ## Find the session key
28
+
29
+ Use the first available value, without echoing it:
30
+
31
+ ```bash
32
+ KEY="${SESSION_API_KEY:-${OH_SESSION_API_KEYS_0:-${LOCAL_BACKEND_API_KEY:-}}}"
33
+ if [ -z "$KEY" ] && [ -f "$HOME/.openhands/agent-canvas/api-key.txt" ]; then
34
+ KEY="$(tr -d '\n' < "$HOME/.openhands/agent-canvas/api-key.txt")"
35
+ fi
36
+ test -n "$KEY" || { echo "No Agent Canvas session API key found" >&2; exit 1; }
37
+ ```
38
+
39
+ Validate backend access:
40
+
41
+ ```bash
42
+ curl -sS -o /tmp/agent-canvas-conversations.json -w '%{http_code}\n' \
43
+ -H "X-Session-API-Key: $KEY" \
44
+ http://localhost:8001/api/conversations/search
45
+ ```
46
+
47
+ HTTP `200` means the backend and key work.
48
+
49
+ ## Delegate to a local conversation
50
+
51
+ Use `POST /api/conversations` with:
52
+
53
+ - current `/api/settings` as the base agent profile
54
+ - a fresh absolute workspace directory
55
+ - `initial_message.run: true`
56
+ - `worktree: false` when the workspace is already isolated
57
+
58
+ Template:
59
+
60
+ ```bash
61
+ set -euo pipefail
62
+
63
+ BASE="${AGENT_CANVAS_BACKEND:-http://localhost:8001}"
64
+ KEY="${SESSION_API_KEY:-${OH_SESSION_API_KEYS_0:-${LOCAL_BACKEND_API_KEY:-}}}"
65
+ if [ -z "$KEY" ] && [ -f "$HOME/.openhands/agent-canvas/api-key.txt" ]; then
66
+ KEY="$(tr -d '\n' < "$HOME/.openhands/agent-canvas/api-key.txt")"
67
+ fi
68
+ test -n "$KEY" || { echo "No Agent Canvas session API key found" >&2; exit 1; }
69
+
70
+ WORKDIR="${WORKDIR:-$HOME/workspace/delegated/$(date +%Y%m%d-%H%M%S)}"
71
+ mkdir -p "$WORKDIR"
72
+
73
+ SETTINGS_JSON="$(curl -sS -H "X-Session-API-Key: $KEY" "$BASE/api/settings")"
74
+ PROMPT='Write a complete, task-specific prompt here. Include repo, branch, constraints, validation, and expected report.'
75
+
76
+ PAYLOAD="$(jq -n --argjson settings "$SETTINGS_JSON" --arg prompt "$PROMPT" --arg workdir "$WORKDIR" '
77
+ def agent_settings:
78
+ ($settings.agent_settings // {})
79
+ | del(.schema_version)
80
+ | . + {
81
+ agent_context: ((.agent_context // {}) + {
82
+ load_public_skills: true,
83
+ load_user_skills: true,
84
+ load_project_skills: true
85
+ })
86
+ };
87
+ ($settings.conversation_settings // {}) as $conv |
88
+ {
89
+ agent_settings: agent_settings,
90
+ workspace: {kind: "LocalWorkspace", working_dir: $workdir},
91
+ confirmation_policy: {kind: "NeverConfirm"},
92
+ max_iterations: (($conv.max_iterations // 80) | if . == null then 80 else . end),
93
+ stuck_detection: true,
94
+ autotitle: true,
95
+ worktree: false,
96
+ initial_message: {
97
+ role: "user",
98
+ content: [{type: "text", text: $prompt}],
99
+ run: true
100
+ }
101
+ }
102
+ ')"
103
+
104
+ curl -sS -X POST "$BASE/api/conversations" \
105
+ -H "Content-Type: application/json" \
106
+ -H "X-Session-API-Key: $KEY" \
107
+ --data-binary "$PAYLOAD" | jq '{id, title, execution_status, workspace}'
108
+ ```
109
+
110
+ Report both links:
111
+
112
+ - UI: `http://localhost:8000/conversations/<conversation_id>`
113
+ - API: `http://localhost:8001/api/conversations/<conversation_id>`
114
+
115
+ ## Monitor a delegated conversation
116
+
117
+ ```bash
118
+ CID="<conversation_id>"
119
+ curl -sS -H "X-Session-API-Key: $KEY" "$BASE/api/conversations/$CID" \
120
+ | jq '{id, title, execution_status, updated_at, workspace, agent_kind: .agent.kind, current_model_id, current_model_name}'
121
+
122
+ curl -sS -H "X-Session-API-Key: $KEY" "$BASE/api/conversations/$CID/events/search?limit=20" \
123
+ | jq '.events // .items // .'
124
+ ```
125
+
126
+ Terminal statuses commonly include `idle`, `running`, `finished`, `error`, `stuck`, and `stopped`.
127
+
128
+ ## Prompt checklist for delegation
129
+
130
+ Include:
131
+
132
+ - repository owner/name and local path if relevant
133
+ - branch, PR, issue, or Linear ticket identifiers
134
+ - current status and known blockers
135
+ - exact files or subsystems in scope
136
+ - dirty-worktree warnings and paths not to touch
137
+ - whether to push, open a PR, or only report
138
+ - checks/tests to run
139
+ - expected final report format
140
+
141
+ Do not rely on the new conversation knowing anything from the current thread.
@@ -1,12 +1,19 @@
1
- # State File Schema
1
+ # State Schema
2
2
 
3
- The automation maintains a JSON state file that persists across polling runs.
3
+ The automation maintains a JSON state document that persists across polling runs.
4
4
  It is the source of truth for which trigger-label events have queued reviews
5
5
  and which conversations are still active.
6
6
 
7
7
  ---
8
8
 
9
- ## File Location
9
+ ## Storage
10
+
11
+ **Primary (cloud):** The state is stored in the automation service's built-in KV
12
+ store under the key `"state"`. The KV store is available when `AUTOMATION_KV_TOKEN`
13
+ is injected into the run environment. Each automation has its own isolated namespace.
14
+
15
+ **Fallback (local/dev):** When the KV store is not available, the state is written
16
+ to a local JSON file at:
10
17
 
11
18
  ```
12
19
  {WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_label_event_{automation_id}.json
@@ -116,8 +123,15 @@ Trigger label applied on GitHub
116
123
  ## Resetting State
117
124
 
118
125
  To force the automation to reconsider previous label events, delete the state
119
- file:
126
+ from the KV store (cloud) or the fallback file (local).
127
+
128
+ **Cloud (KV store):**
129
+ ```bash
130
+ curl -X DELETE "${OPENHANDS_HOST}/api/automation/v1/kv/state" \
131
+ -H "Authorization: Bearer ${AUTOMATION_KV_TOKEN}"
132
+ ```
120
133
 
134
+ **Local (file fallback):**
121
135
  ```bash
122
136
  rm ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_<id>.json
123
137
  ```
@@ -67,6 +67,45 @@ def fire_callback(
67
67
  print(f"Callback error (non-fatal): {exc}")
68
68
 
69
69
 
70
+ # ── State persistence (KV store with local-file fallback) ─────────────────────
71
+
72
+ _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
73
+ _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
74
+ _STATE_KEY = "state"
75
+
76
+
77
+ def _kv_available() -> bool:
78
+ return bool(_KV_TOKEN and _KV_BASE)
79
+
80
+
81
+ def _kv_get(key: str) -> dict | None:
82
+ req = urllib.request.Request(
83
+ f"{_KV_BASE}/v1/kv/{key}",
84
+ headers={"Authorization": f"Bearer {_KV_TOKEN}"},
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(req) as r:
88
+ return json.loads(r.read())["value"]
89
+ except urllib.error.HTTPError as exc:
90
+ if exc.code == 404:
91
+ return None
92
+ raise
93
+
94
+
95
+ def _kv_set(key: str, value: dict) -> None:
96
+ req = urllib.request.Request(
97
+ f"{_KV_BASE}/v1/kv/{key}",
98
+ data=json.dumps(value).encode(),
99
+ headers={
100
+ "Authorization": f"Bearer {_KV_TOKEN}",
101
+ "Content-Type": "application/json",
102
+ },
103
+ method="PUT",
104
+ )
105
+ with urllib.request.urlopen(req) as r:
106
+ r.read()
107
+
108
+
70
109
  def _state_file_path() -> str:
71
110
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
72
111
  event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
@@ -82,13 +121,7 @@ def _state_file_path() -> str:
82
121
  return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")
83
122
 
84
123
 
85
- def load_state(path: str) -> dict:
86
- if os.path.exists(path):
87
- try:
88
- with open(path) as f:
89
- return json.load(f)
90
- except (json.JSONDecodeError, OSError) as exc:
91
- print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
124
+ def _default_state() -> dict:
92
125
  return {
93
126
  "version": 2,
94
127
  "repo": REPO,
@@ -98,11 +131,34 @@ def load_state(path: str) -> dict:
98
131
  }
99
132
 
100
133
 
101
- def save_state(path: str, state: dict) -> None:
134
+ def load_state() -> dict:
135
+ if _kv_available():
136
+ data = _kv_get(_STATE_KEY)
137
+ if data is not None:
138
+ print("State loaded from KV store")
139
+ return data
140
+ return _default_state()
141
+ path = _state_file_path()
142
+ if os.path.exists(path):
143
+ try:
144
+ with open(path) as f:
145
+ return json.load(f)
146
+ except (json.JSONDecodeError, OSError) as exc:
147
+ print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
148
+ return _default_state()
149
+
150
+
151
+ def save_state(state: dict) -> None:
152
+ if _kv_available():
153
+ _kv_set(_STATE_KEY, state)
154
+ print("State saved to KV store")
155
+ return
156
+ path = _state_file_path()
102
157
  tmp_path = f"{path}.tmp"
103
158
  with open(tmp_path, "w") as f:
104
159
  json.dump(state, f, indent=2, sort_keys=True)
105
160
  os.replace(tmp_path, path)
161
+ print(f"State saved to {path}")
106
162
 
107
163
 
108
164
  def _github_request(
@@ -522,8 +578,7 @@ def _check_conversation_completion(
522
578
 
523
579
 
524
580
  def main() -> str | None:
525
- state_path = _state_file_path()
526
- state = load_state(state_path)
581
+ state = load_state()
527
582
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
528
583
  api_key = _get_env_key()
529
584
 
@@ -591,8 +646,7 @@ def main() -> str | None:
591
646
  state["repo"] = REPO
592
647
  state["trigger_label"] = TRIGGER_LABEL
593
648
  state["updated_at"] = time.time()
594
- save_state(state_path, state)
595
- print(f"State saved → {state_path}")
649
+ save_state(state)
596
650
  return last_conversation_id
597
651
 
598
652
 
@@ -1,12 +1,19 @@
1
- # State File Schema
1
+ # State Schema
2
2
 
3
- The automation maintains a JSON state file that persists across polling runs.
4
- This file is the source of truth for which conversations are active, which
3
+ The automation maintains a JSON state document that persists across polling runs.
4
+ This document is the source of truth for which conversations are active, which
5
5
  comment IDs have already been processed, and the timestamp of the last poll.
6
6
 
7
7
  ---
8
8
 
9
- ## File Location
9
+ ## Storage
10
+
11
+ **Primary (cloud):** The state is stored in the automation service's built-in KV
12
+ store under the key `"state"`. The KV store is available when `AUTOMATION_KV_TOKEN`
13
+ is injected into the run environment. Each automation has its own isolated namespace.
14
+
15
+ **Fallback (local/dev):** When the KV store is not available, the state is written
16
+ to a local JSON file at:
10
17
 
11
18
  ```
12
19
  {WORKSPACE_BASE_ROOT}/automation-state/github_poller_{automation_id}.json
@@ -108,14 +108,47 @@ def fire_callback(
108
108
  print(f"Callback error (non-fatal): {exc}")
109
109
 
110
110
 
111
- # ── State management ───────────────────────────────────────────────────────────
111
+ # ── State persistence (KV store with local-file fallback) ─────────────────────
112
112
 
113
- def _state_file_path() -> str:
114
- """Derive a persistent storage path from WORKSPACE_BASE.
113
+ _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
114
+ _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
115
+ _STATE_KEY = "state"
115
116
 
116
- WORKSPACE_BASE = {root}/automation-runs/{run_id}
117
- State lives two levels up at {root}/automation-state/.
118
- """
117
+
118
+ def _kv_available() -> bool:
119
+ return bool(_KV_TOKEN and _KV_BASE)
120
+
121
+
122
+ def _kv_get(key: str) -> dict | None:
123
+ req = urllib.request.Request(
124
+ f"{_KV_BASE}/v1/kv/{key}",
125
+ headers={"Authorization": f"Bearer {_KV_TOKEN}"},
126
+ )
127
+ try:
128
+ with urllib.request.urlopen(req) as r:
129
+ return json.loads(r.read())["value"]
130
+ except urllib.error.HTTPError as exc:
131
+ if exc.code == 404:
132
+ return None
133
+ raise
134
+
135
+
136
+ def _kv_set(key: str, value: dict) -> None:
137
+ req = urllib.request.Request(
138
+ f"{_KV_BASE}/v1/kv/{key}",
139
+ data=json.dumps(value).encode(),
140
+ headers={
141
+ "Authorization": f"Bearer {_KV_TOKEN}",
142
+ "Content-Type": "application/json",
143
+ },
144
+ method="PUT",
145
+ )
146
+ with urllib.request.urlopen(req) as r:
147
+ r.read()
148
+
149
+
150
+ def _state_file_path() -> str:
151
+ """Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
119
152
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
120
153
  event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
121
154
  automation_id = event_payload.get("automation_id", "default")
@@ -137,25 +170,42 @@ def _default_since() -> str:
137
170
  ).strftime("%Y-%m-%dT%H:%M:%SZ")
138
171
 
139
172
 
140
- def load_state(path: str) -> dict:
173
+ def _default_state() -> dict:
174
+ return {
175
+ "version": 1,
176
+ "repo": REPO,
177
+ "last_poll": _default_since(),
178
+ "conversations": {},
179
+ "processed_comment_ids": [],
180
+ }
181
+
182
+
183
+ def load_state() -> dict:
184
+ if _kv_available():
185
+ data = _kv_get(_STATE_KEY)
186
+ if data is not None:
187
+ print("State loaded from KV store")
188
+ return data
189
+ return _default_state()
190
+ path = _state_file_path()
141
191
  if os.path.exists(path):
142
192
  try:
143
193
  with open(path) as f:
144
194
  return json.load(f)
145
195
  except (json.JSONDecodeError, OSError) as exc:
146
196
  print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
147
- return {
148
- "version": 1,
149
- "repo": REPO,
150
- "last_poll": _default_since(),
151
- "conversations": {}, # issue_number (str) → ConversationRecord
152
- "processed_comment_ids": [], # list of int comment IDs already handled
153
- }
197
+ return _default_state()
154
198
 
155
199
 
156
- def save_state(path: str, state: dict) -> None:
200
+ def save_state(state: dict) -> None:
201
+ if _kv_available():
202
+ _kv_set(_STATE_KEY, state)
203
+ print("State saved to KV store")
204
+ return
205
+ path = _state_file_path()
157
206
  with open(path, "w") as f:
158
207
  json.dump(state, f, indent=2)
208
+ print(f"State saved to {path}")
159
209
 
160
210
 
161
211
  # ── GitHub API helpers ─────────────────────────────────────────────────────────
@@ -797,8 +847,7 @@ def _check_conversation_completion(
797
847
 
798
848
  def main() -> str | None:
799
849
  """Run one polling cycle. Returns the last conversation ID created, if any."""
800
- state_path = _state_file_path()
801
- state = load_state(state_path)
850
+ state = load_state()
802
851
 
803
852
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
804
853
  api_key = _get_env_key()
@@ -899,8 +948,7 @@ def main() -> str | None:
899
948
  state["processed_comment_ids"] = trimmed
900
949
  state["conversations"] = conversations
901
950
 
902
- save_state(state_path, state)
903
- print(f"State saved → {state_path}")
951
+ save_state(state)
904
952
  return last_conversation_id
905
953
 
906
954
 
@@ -29,10 +29,20 @@ def _make_comment(body="hello @openhands", login="octocat", user_type="User"):
29
29
 
30
30
  # ── State file tests ───────────────────────────────────────────────────────────
31
31
 
32
+ def _no_kv(path):
33
+ """Return a pair of patches: KV disabled, file path pinned to *path*."""
34
+ return (
35
+ patch("main._kv_available", return_value=False),
36
+ patch("main._state_file_path", return_value=path),
37
+ )
38
+
39
+
32
40
  class TestLoadState(unittest.TestCase):
33
41
 
34
42
  def test_missing_file_returns_default(self):
35
- state = main.load_state("/nonexistent/path/state.json")
43
+ with patch("main._kv_available", return_value=False), \
44
+ patch("main._state_file_path", return_value="/nonexistent/path/state.json"):
45
+ state = main.load_state()
36
46
  self.assertIn("conversations", state)
37
47
  self.assertIn("processed_comment_ids", state)
38
48
  self.assertEqual(state["version"], 1)
@@ -42,7 +52,9 @@ class TestLoadState(unittest.TestCase):
42
52
  json.dump({"version": 1, "custom": "value", "conversations": {}}, f)
43
53
  path = f.name
44
54
  try:
45
- state = main.load_state(path)
55
+ with patch("main._kv_available", return_value=False), \
56
+ patch("main._state_file_path", return_value=path):
57
+ state = main.load_state()
46
58
  self.assertEqual(state["custom"], "value")
47
59
  finally:
48
60
  os.unlink(path)
@@ -52,7 +64,9 @@ class TestLoadState(unittest.TestCase):
52
64
  f.write("{this is not valid json!!!}")
53
65
  path = f.name
54
66
  try:
55
- state = main.load_state(path)
67
+ with patch("main._kv_available", return_value=False), \
68
+ patch("main._state_file_path", return_value=path):
69
+ state = main.load_state()
56
70
  # Should return the default state rather than raising.
57
71
  self.assertIn("conversations", state)
58
72
  self.assertEqual(state["version"], 1)
@@ -64,7 +78,9 @@ class TestLoadState(unittest.TestCase):
64
78
  f.write("")
65
79
  path = f.name
66
80
  try:
67
- state = main.load_state(path)
81
+ with patch("main._kv_available", return_value=False), \
82
+ patch("main._state_file_path", return_value=path):
83
+ state = main.load_state()
68
84
  self.assertIn("conversations", state)
69
85
  finally:
70
86
  os.unlink(path)
@@ -81,14 +97,55 @@ class TestSaveAndLoadRoundtrip(unittest.TestCase):
81
97
  with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
82
98
  path = f.name
83
99
  try:
84
- main.save_state(path, data)
85
- loaded = main.load_state(path)
100
+ with patch("main._kv_available", return_value=False), \
101
+ patch("main._state_file_path", return_value=path):
102
+ main.save_state(data)
103
+ loaded = main.load_state()
86
104
  self.assertEqual(loaded["conversations"]["42"]["conversation_id"], "abc")
87
105
  self.assertEqual(loaded["processed_comment_ids"], [1, 2, 3])
88
106
  finally:
89
107
  os.unlink(path)
90
108
 
91
109
 
110
+ class TestKVState(unittest.TestCase):
111
+
112
+ def test_load_reads_value_from_kv(self):
113
+ kv_data = {"version": 1, "conversations": {"7": {"conversation_id": "kv-c"}},
114
+ "processed_comment_ids": []}
115
+ with patch("main._kv_available", return_value=True), \
116
+ patch("main._kv_get", return_value=kv_data) as mock_get:
117
+ state = main.load_state()
118
+ self.assertEqual(state["conversations"]["7"]["conversation_id"], "kv-c")
119
+ mock_get.assert_called_once_with("state")
120
+
121
+ def test_load_returns_default_on_kv_miss(self):
122
+ with patch("main._kv_available", return_value=True), \
123
+ patch("main._kv_get", return_value=None):
124
+ state = main.load_state()
125
+ self.assertIn("conversations", state)
126
+ self.assertEqual(state["version"], 1)
127
+
128
+ def test_save_writes_to_kv(self):
129
+ data = {"version": 1, "conversations": {}, "processed_comment_ids": []}
130
+ with patch("main._kv_available", return_value=True), \
131
+ patch("main._kv_set") as mock_set:
132
+ main.save_state(data)
133
+ mock_set.assert_called_once_with("state", data)
134
+
135
+ def test_kv_error_propagates_on_load(self):
136
+ with patch("main._kv_available", return_value=True), \
137
+ patch("main._kv_get", side_effect=RuntimeError("network error")):
138
+ with self.assertRaises(RuntimeError):
139
+ main.load_state()
140
+
141
+ def test_kv_error_propagates_on_save(self):
142
+ data = {"version": 1, "conversations": {}, "processed_comment_ids": []}
143
+ with patch("main._kv_available", return_value=True), \
144
+ patch("main._kv_set", side_effect=RuntimeError("network error")):
145
+ with self.assertRaises(RuntimeError):
146
+ main.save_state(data)
147
+
148
+
92
149
  # ── Bot detection tests ────────────────────────────────────────────────────────
93
150
 
94
151
  class TestIsBotComment(unittest.TestCase):
@@ -214,8 +271,10 @@ class TestProcessedIdDeduplication(unittest.TestCase):
214
271
  try:
215
272
  state = {"version": 1, "conversations": {},
216
273
  "processed_comment_ids": [101, 202, 303]}
217
- main.save_state(path, state)
218
- loaded = main.load_state(path)
274
+ with patch("main._kv_available", return_value=False), \
275
+ patch("main._state_file_path", return_value=path):
276
+ main.save_state(state)
277
+ loaded = main.load_state()
219
278
  self.assertIn(101, loaded["processed_comment_ids"])
220
279
  self.assertIn(303, loaded["processed_comment_ids"])
221
280
  self.assertNotIn(404, loaded["processed_comment_ids"])