machina-cli 0.6.2__tar.gz → 0.7.0__tar.gz
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.
- {machina_cli-0.6.2 → machina_cli-0.7.0}/CHANGELOG.md +10 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/PKG-INFO +1 -1
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/README.md +21 -0
- machina_cli-0.7.0/docs/harness-loop-kit/nodes.py +374 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/.vitepress/config.mts +1 -0
- machina_cli-0.7.0/docs-site/commands/approvals.md +63 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/index.md +1 -1
- {machina_cli-0.6.2 → machina_cli-0.7.0}/pyproject.toml +1 -1
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/__init__.py +1 -1
- machina_cli-0.7.0/src/machina_cli/commands/approvals.py +122 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/main.py +3 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/repl.py +3 -0
- machina_cli-0.7.0/tests/test_approvals.py +96 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/.github/workflows/ci.yml +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/.github/workflows/release.yml +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/.gitignore +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/CLAUDE.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/README.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/agentic-harness-loop.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/credentials.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/PLAYBOOK-SCORECARD.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/VALIDATION.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/context-verify.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/provision.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/harness-loop-kit/surface-verify.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs/loop-tools-connector.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/.gitignore +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/.vitepress/theme/custom.css +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/.vitepress/theme/index.ts +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/README.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/agent.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/config.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/connect.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/connector.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/context-graph.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/credentials.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/deploy.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/document.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/execution.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/factory.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/login.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/loop.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/mapping.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/mcp.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/org.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/project.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/prompt.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/skills.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/sports.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/template.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/update.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/commands/workflow.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/authentication.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/configuration.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/installation.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/quickstart.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/repl.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/guide/running.md +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/package-lock.json +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/package.json +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/public/favicon.ico +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/public/machina-logo-dark.png +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/public/machina-logo-light.png +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/docs-site/vercel.json +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/install.ps1 +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/install.sh +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/browser_auth.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/client.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/__init__.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/agent.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/auth.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/config_cmd.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/connect.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/connector.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/context_graph.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/credentials.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/deploy.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/document.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/execution.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/factory.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/loop.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/mapping.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/mcp.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/org.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/project.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/prompt.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/skills.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/sports.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/template.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/commands/workflow.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/config.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/factory_client.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/loop_client.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/models/__init__.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/project_client.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/update_check.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/src/machina_cli/updater.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/__init__.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_cli_output.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_connect.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_context_graph_timeline.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_mcp_url.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_org_usage.py +0 -0
- {machina_cli-0.6.2 → machina_cli-0.7.0}/tests/test_sports_integration.py +0 -0
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to machina-cli are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Human approval checkpoints — `machina approvals` + the `compose_approval`/`resolve_approval` nodes.** A workflow that produces something risky or publishable (an AI-written article, a bulk change) can now gate its final action behind a human: it composes an `approval-request` document (title, preview, and the workflow+inputs that should run **if approved**), saves it, and posts the ask to Slack — the request side is three tasks from the `machina-nodes` library (the provisioned `machina-approval-demo` is a copyable example; an actionless request works too, as a pure acknowledgment gate). The human resolves via `machina approvals list / approve / reject`; resolution executes the in-pod `machina-approval-resolve` workflow, so every surface (CLI, Studio, MCP) shares the same logic — approve dispatches the stored action in-pod, reject just records, double-resolution is guarded, and a dispatch failure is recorded and surfaced without un-approving the decision. This is the Pressbox adoption plan's step ② and the Loop-Engineering "keep one door open" checkpoint as a first-class workflow primitive.
|
|
9
|
+
|
|
10
|
+
## [0.6.3] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`machina-nodes` — a shared action-node library for pod workflows** (`docs/harness-loop-kit/nodes.py`). The Pressbox lesson applied to Machina's own engine: workflows are already data walked by an executor — what was missing was a registry of small, composable action nodes, so a new automation is graph wiring instead of another copy-pasted pyscript (the Slack notify logic had been written twice across the verify kits). Ships two nodes: `slack_notify` (posts a ready-composed text; webhook from the same `slack-notify-config` doc the verifies use) and `github_issue` (opens a GitHub issue; token+repo from a `github-issue-config` doc — **inert until configured**, so it deploys everywhere safely). Every node is best-effort (never raises), inert without config, and composed via task inputs. A `machina-nodes-demo` workflow ships as living documentation of the pattern.
|
|
14
|
+
|
|
5
15
|
## [0.6.2] - 2026-07-01
|
|
6
16
|
|
|
7
17
|
### Fixed
|
|
@@ -183,6 +183,27 @@ pelo LLM e persistido pra a montagem de contexto do cliente consumir. O agent
|
|
|
183
183
|
detect → heal → **resolve ids** → persist → repeat. *(Conservador: só links que resolvem a ids
|
|
184
184
|
reais são gravados — o resto fica como órfão.)*
|
|
185
185
|
|
|
186
|
+
## 6.1 `nodes.py` — biblioteca de nós compartilhados (padrão Pressbox)
|
|
187
|
+
|
|
188
|
+
Os workflows da Machina já são *dados* (tasks + conditions + `$.get()`), mas cada
|
|
189
|
+
automação nova vinha copiando código de ação (o notify do Slack foi escrito 2x nos
|
|
190
|
+
verifies). **`nodes.py`** provisiona o connector **`machina-nodes`** — um *registry*
|
|
191
|
+
de nós de ação pequenos e compostos no grafo, não no código:
|
|
192
|
+
|
|
193
|
+
| Nó | Faz | Config |
|
|
194
|
+
| --- | --- | --- |
|
|
195
|
+
| `slack_notify` | posta um texto **já composto** no webhook do pod | doc `slack-notify-config` (o mesmo dos verifies) |
|
|
196
|
+
| `github_issue` | abre issue no GitHub (title/body/labels) | doc `github-issue-config` `{token, repo}` — **inerte até existir** |
|
|
197
|
+
|
|
198
|
+
Regras de todo nó: **best-effort** (nunca lança — automação acessória não pode derrubar
|
|
199
|
+
o workflow que a carrega), **inerte sem config**, **composição via inputs da task**
|
|
200
|
+
(o workflow compõe; o nó só executa). O workflow `machina-nodes-demo` fica provisionado
|
|
201
|
+
como documentação viva do padrão.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
CLIENT_API_URL=… API_TOKEN=… python3 docs/harness-loop-kit/nodes.py --run # provisiona + smoke no Slack
|
|
205
|
+
```
|
|
206
|
+
|
|
186
207
|
## 7. Pendências honestas (transparência)
|
|
187
208
|
|
|
188
209
|
1. **Redeploy do MCP** ([#287](https://github.com/machina-sports/machina-client-api/issues/287)):
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Machina Nodes — a shared, reusable node library for pod workflows.
|
|
4
|
+
|
|
5
|
+
The Pressbox lesson applied to Machina's own engine: workflows here are already
|
|
6
|
+
data (tasks + conditions + $.get() templating) walked by an executor — what was
|
|
7
|
+
missing is a REGISTRY of small, composable action nodes, so a new automation is
|
|
8
|
+
graph wiring instead of another copy-pasted pyscript. This kit provisions one
|
|
9
|
+
connector, `machina-nodes`, whose commands are those nodes:
|
|
10
|
+
|
|
11
|
+
slack_notify post a ready-composed text to the pod's Slack webhook.
|
|
12
|
+
Resolves the webhook from the `slack-notify-config` document
|
|
13
|
+
(same doc surface-verify/context-verify already use) or an
|
|
14
|
+
explicit input. No text / no webhook -> clean no-op.
|
|
15
|
+
github_issue open a GitHub issue (title/body/labels). Token + default repo
|
|
16
|
+
come from a `github-issue-config` document ({token, repo}) or
|
|
17
|
+
explicit inputs. INERT until that doc exists -- provisioning
|
|
18
|
+
this connector changes nothing until someone drops a token.
|
|
19
|
+
compose_approval build an approval request (an `approval-request` document
|
|
20
|
+
value + the Slack ask) for content/actions that need a human
|
|
21
|
+
before executing -- the "keep one door open" checkpoint as a
|
|
22
|
+
node. The CARRIER workflow saves the doc and posts the ask
|
|
23
|
+
(see machina-approval-demo); this node only composes.
|
|
24
|
+
resolve_approval act on a human's decision: approve -> dispatch the stored
|
|
25
|
+
action workflow (in-pod), reject -> just record it. Guarded
|
|
26
|
+
against double-resolution. Wired into the provisioned
|
|
27
|
+
`machina-approval-resolve` workflow, which any surface (CLI
|
|
28
|
+
`machina approvals`, Studio, MCP) executes with
|
|
29
|
+
{request_id, decision}.
|
|
30
|
+
|
|
31
|
+
Design rules every node follows (and future nodes must too):
|
|
32
|
+
- best-effort: a node NEVER raises -- an automation nicety must never fail
|
|
33
|
+
the workflow that carries it. Failures return {..., "error": "..."}.
|
|
34
|
+
- inert by default: missing config -> {"skipped": "..."}, zero side effects.
|
|
35
|
+
- composition over configuration: nodes take READY inputs (text, title);
|
|
36
|
+
composing them is the calling workflow's job, in task inputs -- that keeps
|
|
37
|
+
each node small and every combination possible.
|
|
38
|
+
|
|
39
|
+
`machina-nodes-demo` (compose-in-inputs -> post) and `machina-approval-demo`
|
|
40
|
+
(compose_approval -> save doc -> slack ask; the approved action itself runs
|
|
41
|
+
machina-nodes-demo) are provisioned as living documentation of the patterns.
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
CLIENT_API_URL="https://<org>-<project>.org.machina.gg" \\
|
|
45
|
+
API_TOKEN="<project X-Api-Token>" \\
|
|
46
|
+
python3 nodes.py # provision the connector + demo workflow
|
|
47
|
+
python3 nodes.py --run # provision + post one marked smoke message
|
|
48
|
+
python3 nodes.py --teardown # remove
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
|
|
55
|
+
import urllib.request
|
|
56
|
+
import urllib.error
|
|
57
|
+
|
|
58
|
+
BASE = os.environ.get("CLIENT_API_URL", "").rstrip("/")
|
|
59
|
+
TOKEN = os.environ.get("API_TOKEN", "")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _req(method, path, body=None):
|
|
63
|
+
url = f"{BASE}/{path.lstrip('/')}"
|
|
64
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
65
|
+
req = urllib.request.Request(url, data=data, method=method,
|
|
66
|
+
headers={"X-Api-Token": TOKEN, "Content-Type": "application/json"})
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
69
|
+
return json.loads(r.read() or "{}")
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
return {"status": False, "error": {"message": f"HTTP {e.code}: {e.read()[:200]}"}}
|
|
72
|
+
except Exception as e: # noqa: BLE001
|
|
73
|
+
return {"status": False, "error": {"message": str(e)}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _delete_by_name(kind, name):
|
|
77
|
+
d = _req("GET", f"{kind}/{name}").get("data", {})
|
|
78
|
+
if isinstance(d, dict) and d.get("_id"):
|
|
79
|
+
_req("DELETE", f"{kind}/{d['_id']}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _create(kind, body):
|
|
83
|
+
_delete_by_name(kind, body["name"])
|
|
84
|
+
res = _req("POST", kind, body)
|
|
85
|
+
ok = res.get("status") in (True, "success")
|
|
86
|
+
print(f" {'OK ' if ok else 'ERR'} {kind}/{body['name']}"
|
|
87
|
+
+ ("" if ok else f" -> {json.dumps(res.get('error'))[:120]}"))
|
|
88
|
+
return ok
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
NODES_SRC = r'''"""machina-nodes: small, composable action nodes for pod workflows."""
|
|
92
|
+
import json, os, urllib.request, urllib.error
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _config_doc(name):
|
|
96
|
+
"""Read a single config document's value by name; {} when absent/unreadable."""
|
|
97
|
+
try:
|
|
98
|
+
from core.document.controller import document_search
|
|
99
|
+
r = document_search(filters={"name": name}, page=1, page_size=1)
|
|
100
|
+
dd = r.get("data") if isinstance(r, dict) else None
|
|
101
|
+
rows = dd.get("data") if isinstance(dd, dict) else (dd if isinstance(dd, list) else [])
|
|
102
|
+
if rows:
|
|
103
|
+
return rows[0].get("value") or {}
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def slack_notify(request_data: dict) -> dict:
|
|
110
|
+
"""Post a ready-composed text to Slack. Webhook: explicit input, else the pod's
|
|
111
|
+
slack-notify-config document. No text / unresolved template / no webhook -> no-op.
|
|
112
|
+
Best-effort: never raises."""
|
|
113
|
+
p = request_data.get("params", {}) or request_data
|
|
114
|
+
text = (p.get("text") or "").strip()
|
|
115
|
+
if not text or text.startswith("$"):
|
|
116
|
+
return {"status": True, "data": {"notified": False, "skipped": "no text"}}
|
|
117
|
+
webhook = (p.get("webhook_url") or "").strip()
|
|
118
|
+
if not webhook or webhook.startswith("$"):
|
|
119
|
+
webhook = (_config_doc("slack-notify-config").get("webhook_url") or "").strip()
|
|
120
|
+
if not webhook:
|
|
121
|
+
return {"status": True, "data": {"notified": False, "skipped": "no webhook configured"}}
|
|
122
|
+
try:
|
|
123
|
+
req = urllib.request.Request(webhook, data=json.dumps({"text": text}).encode(),
|
|
124
|
+
method="POST", headers={"Content-Type": "application/json"})
|
|
125
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
126
|
+
r.read()
|
|
127
|
+
return {"status": True, "data": {"notified": True}}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return {"status": True, "data": {"notified": False, "error": str(e)[:150]}}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def github_issue(request_data: dict) -> dict:
|
|
133
|
+
"""Open a GitHub issue. Token + default repo come from the github-issue-config
|
|
134
|
+
document ({token, repo}) or explicit inputs -- INERT until configured. Repo is
|
|
135
|
+
"owner/name". Best-effort: never raises."""
|
|
136
|
+
p = request_data.get("params", {}) or request_data
|
|
137
|
+
title = (p.get("title") or "").strip()
|
|
138
|
+
if not title or title.startswith("$"):
|
|
139
|
+
return {"status": True, "data": {"filed": False, "skipped": "no title"}}
|
|
140
|
+
token = (p.get("token") or "").strip()
|
|
141
|
+
repo = (p.get("repo") or "").strip()
|
|
142
|
+
if not token or not repo or repo.startswith("$"):
|
|
143
|
+
cfg = _config_doc("github-issue-config")
|
|
144
|
+
token = token if token and not token.startswith("$") else (cfg.get("token") or "").strip()
|
|
145
|
+
repo = repo if repo and not repo.startswith("$") else (cfg.get("repo") or "").strip()
|
|
146
|
+
if not token or not repo:
|
|
147
|
+
return {"status": True, "data": {"filed": False, "skipped": "not configured (github-issue-config)"}}
|
|
148
|
+
payload = {"title": title, "body": p.get("body") or ""}
|
|
149
|
+
labels = p.get("labels")
|
|
150
|
+
if isinstance(labels, list) and labels:
|
|
151
|
+
payload["labels"] = labels
|
|
152
|
+
try:
|
|
153
|
+
req = urllib.request.Request(
|
|
154
|
+
"https://api.github.com/repos/%s/issues" % repo,
|
|
155
|
+
data=json.dumps(payload).encode(), method="POST",
|
|
156
|
+
headers={"Authorization": "Bearer " + token,
|
|
157
|
+
"Accept": "application/vnd.github+json",
|
|
158
|
+
# GitHub's API rejects requests without a User-Agent.
|
|
159
|
+
"User-Agent": "machina-nodes",
|
|
160
|
+
"Content-Type": "application/json"})
|
|
161
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
162
|
+
d = json.loads(r.read() or "{}")
|
|
163
|
+
return {"status": True, "data": {"filed": True, "issue_url": d.get("html_url"),
|
|
164
|
+
"number": d.get("number")}}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return {"status": True, "data": {"filed": False, "error": str(e)[:150]}}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def compose_approval(request_data: dict) -> dict:
|
|
170
|
+
"""Build an approval request: the `approval-request` document value + the Slack ask.
|
|
171
|
+
Pure composition -- the carrier workflow saves the doc and posts the text (see the
|
|
172
|
+
machina-approval-demo graph). action_workflow/action_inputs describe what runs IF a
|
|
173
|
+
human approves; nothing executes here."""
|
|
174
|
+
import uuid
|
|
175
|
+
from datetime import datetime, timezone
|
|
176
|
+
p = request_data.get("params", {}) or request_data
|
|
177
|
+
title = (p.get("title") or "").strip()
|
|
178
|
+
if not title or title.startswith("$"):
|
|
179
|
+
return {"status": True, "data": {"composed": False, "skipped": "no title"}}
|
|
180
|
+
request_id = uuid.uuid4().hex[:10]
|
|
181
|
+
preview = str(p.get("preview") or "")[:2000]
|
|
182
|
+
action_workflow = (p.get("action_workflow") or "").strip()
|
|
183
|
+
action_inputs = p.get("action_inputs") if isinstance(p.get("action_inputs"), dict) else {}
|
|
184
|
+
approval = {"request_id": request_id, "title": title, "preview": preview,
|
|
185
|
+
"action": {"workflow": action_workflow, "inputs": action_inputs},
|
|
186
|
+
"status": "pending", "requested_by": p.get("requested_by") or "workflow",
|
|
187
|
+
"requested_at": datetime.now(timezone.utc).isoformat()}
|
|
188
|
+
lines = [":raised_hand: *Approval requested* (`%s`)" % request_id, ">*%s*" % title]
|
|
189
|
+
if preview:
|
|
190
|
+
lines.append(">%s" % preview[:600].replace("\n", "\n>"))
|
|
191
|
+
lines.append(">Approve: `machina approvals approve %s` . Reject: `machina approvals reject %s`"
|
|
192
|
+
% (request_id, request_id))
|
|
193
|
+
return {"status": True, "data": {"composed": True, "approval": approval,
|
|
194
|
+
"slack_text": "\n".join(lines)}}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve_approval(request_data: dict) -> dict:
|
|
198
|
+
"""Act on a human decision for an approval request. approve -> dispatch the stored
|
|
199
|
+
action workflow in-pod; reject -> just record. Guarded against double-resolution.
|
|
200
|
+
Returns the updated doc value for the carrier workflow's document-update task --
|
|
201
|
+
this node does not write documents itself."""
|
|
202
|
+
from datetime import datetime, timezone
|
|
203
|
+
p = request_data.get("params", {}) or request_data
|
|
204
|
+
request_id = (p.get("request_id") or "").strip()
|
|
205
|
+
decision = (p.get("decision") or "").strip().lower()
|
|
206
|
+
if not request_id or request_id.startswith("$"):
|
|
207
|
+
return {"status": True, "data": {"resolved": False, "error": "no request_id"}}
|
|
208
|
+
if decision not in ("approve", "reject"):
|
|
209
|
+
return {"status": True, "data": {"resolved": False, "error": "decision must be approve|reject"}}
|
|
210
|
+
|
|
211
|
+
doc_id, value = None, None
|
|
212
|
+
try:
|
|
213
|
+
from core.document.controller import document_search
|
|
214
|
+
r = document_search(filters={"name": "approval-request", "value.request_id": request_id},
|
|
215
|
+
page=1, page_size=1)
|
|
216
|
+
dd = r.get("data") if isinstance(r, dict) else None
|
|
217
|
+
rows = dd.get("data") if isinstance(dd, dict) else (dd if isinstance(dd, list) else [])
|
|
218
|
+
if rows:
|
|
219
|
+
doc_id = rows[0].get("_id")
|
|
220
|
+
value = rows[0].get("value") or {}
|
|
221
|
+
except Exception as e:
|
|
222
|
+
return {"status": True, "data": {"resolved": False, "error": "lookup failed: %s" % str(e)[:120]}}
|
|
223
|
+
if not doc_id:
|
|
224
|
+
return {"status": True, "data": {"resolved": False, "error": "request %s not found" % request_id}}
|
|
225
|
+
if value.get("status") != "pending":
|
|
226
|
+
return {"status": True, "data": {"resolved": False,
|
|
227
|
+
"skipped": "already %s" % value.get("status")}}
|
|
228
|
+
|
|
229
|
+
dispatch = {"dispatched": False}
|
|
230
|
+
if decision == "approve":
|
|
231
|
+
action = value.get("action") or {}
|
|
232
|
+
wf = (action.get("workflow") or "").strip()
|
|
233
|
+
if wf:
|
|
234
|
+
token = os.environ.get("MACHINA_PROJECT_KEY", "")
|
|
235
|
+
try:
|
|
236
|
+
req = urllib.request.Request("http://localhost:5003/workflow/execute/" + wf,
|
|
237
|
+
data=json.dumps(action.get("inputs") or {}).encode(), method="POST",
|
|
238
|
+
headers={"X-Api-Token": token, "Content-Type": "application/json"})
|
|
239
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
240
|
+
r.read()
|
|
241
|
+
dispatch = {"dispatched": True, "workflow": wf}
|
|
242
|
+
except Exception as e:
|
|
243
|
+
# the human approved; record the decision but surface the dispatch failure
|
|
244
|
+
dispatch = {"dispatched": False, "workflow": wf, "error": str(e)[:150]}
|
|
245
|
+
|
|
246
|
+
new_value = dict(value)
|
|
247
|
+
new_value["status"] = "approved" if decision == "approve" else "rejected"
|
|
248
|
+
new_value["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
|
249
|
+
new_value["resolved_by"] = p.get("resolver") or "unknown"
|
|
250
|
+
new_value["dispatch"] = dispatch
|
|
251
|
+
icon = ":white_check_mark:" if decision == "approve" else ":no_entry_sign:"
|
|
252
|
+
note = ""
|
|
253
|
+
if decision == "approve":
|
|
254
|
+
note = (" -- action `%s` dispatched" % dispatch.get("workflow")) if dispatch.get("dispatched") \
|
|
255
|
+
else ((" -- action dispatch FAILED: %s" % dispatch.get("error")) if dispatch.get("workflow") else "")
|
|
256
|
+
slack_text = "%s *Approval %s* (`%s`) -- %s%s" % (
|
|
257
|
+
icon, new_value["status"], request_id, value.get("title", ""), note)
|
|
258
|
+
return {"status": True, "data": {"resolved": True, "doc_id": doc_id, "new_value": new_value,
|
|
259
|
+
"dispatch": dispatch, "slack_text": slack_text}}
|
|
260
|
+
'''
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
APPROVAL_DOC_VAL = "$.get('ap_approval', {})"
|
|
264
|
+
APPROVAL_UPDATED_VAL = "$.get('ap_new_value', {})"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def definitions():
|
|
268
|
+
connector = {"name": "machina-nodes", "title": "Machina Nodes", "status": "active",
|
|
269
|
+
"description": "shared action-node registry (slack_notify, github_issue, approvals) for workflow composition",
|
|
270
|
+
"filename": "machina_nodes.py", "filetype": "pyscript", "filecontent": NODES_SRC,
|
|
271
|
+
"commands": [{"name": "SlackNotify", "value": "slack_notify"},
|
|
272
|
+
{"name": "GithubIssue", "value": "github_issue"},
|
|
273
|
+
{"name": "ComposeApproval", "value": "compose_approval"},
|
|
274
|
+
{"name": "ResolveApproval", "value": "resolve_approval"}]}
|
|
275
|
+
# Living documentation of the composition pattern: the workflow composes the
|
|
276
|
+
# message in task INPUTS; the node just posts it. Any workflow can copy this task.
|
|
277
|
+
demo = {"name": "machina-nodes-demo", "title": "Machina Nodes Demo", "status": "active",
|
|
278
|
+
"description": "composition demo: compose in inputs -> slack_notify node",
|
|
279
|
+
"context-variables": {"debugger": {"enabled": True}},
|
|
280
|
+
"inputs": {"text": "$.get('text', '')"},
|
|
281
|
+
"outputs": {"notified": "$.get('nd_out', {})", "workflow-status": "'executed'"},
|
|
282
|
+
"tasks": [
|
|
283
|
+
{"name": "post", "type": "connector",
|
|
284
|
+
"connector": {"command": "slack_notify", "name": "machina-nodes"},
|
|
285
|
+
"inputs": {"text": "$.get('text', '')"},
|
|
286
|
+
"outputs": {"nd_out": "$"}},
|
|
287
|
+
]}
|
|
288
|
+
# The REQUEST pattern: compose -> save the approval-request doc -> post the ask.
|
|
289
|
+
# Any producer pipeline copies these three tasks (with its own title/preview/action)
|
|
290
|
+
# to gate an action behind a human -- the "keep one door open" checkpoint.
|
|
291
|
+
approval_demo = {
|
|
292
|
+
"name": "machina-approval-demo", "title": "Machina Approval Demo", "status": "active",
|
|
293
|
+
"description": "request pattern: compose_approval -> save doc -> slack ask (approved action runs machina-nodes-demo)",
|
|
294
|
+
"context-variables": {"debugger": {"enabled": True}},
|
|
295
|
+
"inputs": {"title": "$.get('title', 'Demo approval request')",
|
|
296
|
+
"preview": "$.get('preview', 'Approve to post a confirmation message via machina-nodes-demo.')",
|
|
297
|
+
"action_workflow": "$.get('action_workflow', 'machina-nodes-demo')",
|
|
298
|
+
"action_inputs": "$.get('action_inputs', {'text': ':white_check_mark: approved action executed -- this message was gated behind a human approval.'})"},
|
|
299
|
+
"outputs": {"request": "$.get('ap_approval', {})", "workflow-status": "'executed'"},
|
|
300
|
+
"tasks": [
|
|
301
|
+
{"name": "compose", "type": "connector",
|
|
302
|
+
"connector": {"command": "compose_approval", "name": "machina-nodes"},
|
|
303
|
+
"inputs": {"title": "$.get('title')", "preview": "$.get('preview')",
|
|
304
|
+
"action_workflow": "$.get('action_workflow')",
|
|
305
|
+
"action_inputs": "$.get('action_inputs')"},
|
|
306
|
+
"outputs": {"ap_approval": "$.get('approval')", "ap_slack": "$.get('slack_text')",
|
|
307
|
+
"ap_composed": "$.get('composed', False)"}},
|
|
308
|
+
{"name": "save-request", "type": "document",
|
|
309
|
+
"condition": "$.get('ap_composed', False) == True",
|
|
310
|
+
"config": {"action": "save", "embed-vector": False, "force-update": True},
|
|
311
|
+
"documents": {"approval-request": APPROVAL_DOC_VAL}},
|
|
312
|
+
{"name": "ask", "type": "connector",
|
|
313
|
+
"condition": "$.get('ap_composed', False) == True",
|
|
314
|
+
"connector": {"command": "slack_notify", "name": "machina-nodes"},
|
|
315
|
+
"inputs": {"text": "$.get('ap_slack', '')"},
|
|
316
|
+
"outputs": {"ap_notified": "$"}},
|
|
317
|
+
]}
|
|
318
|
+
# The RESOLVE surface: any client (CLI `machina approvals`, Studio, MCP) executes
|
|
319
|
+
# this with {request_id, decision, resolver} -- the in-pod logic stays shared.
|
|
320
|
+
approval_resolve = {
|
|
321
|
+
"name": "machina-approval-resolve", "title": "Machina Approval Resolve", "status": "active",
|
|
322
|
+
"description": "resolve an approval request: approve -> dispatch stored action; reject -> record",
|
|
323
|
+
"context-variables": {"debugger": {"enabled": True}},
|
|
324
|
+
"inputs": {"request_id": "$.get('request_id', '')", "decision": "$.get('decision', '')",
|
|
325
|
+
"resolver": "$.get('resolver', 'unknown')"},
|
|
326
|
+
"outputs": {"resolved": "$.get('ap_resolved', False)", "dispatch": "$.get('ap_dispatch', {})",
|
|
327
|
+
"error": "$.get('ap_error', '')", "workflow-status": "'executed'"},
|
|
328
|
+
"tasks": [
|
|
329
|
+
{"name": "resolve", "type": "connector",
|
|
330
|
+
"connector": {"command": "resolve_approval", "name": "machina-nodes"},
|
|
331
|
+
"inputs": {"request_id": "$.get('request_id')", "decision": "$.get('decision')",
|
|
332
|
+
"resolver": "$.get('resolver')"},
|
|
333
|
+
"outputs": {"ap_resolved": "$.get('resolved', False)", "ap_doc_id": "$.get('doc_id')",
|
|
334
|
+
"ap_new_value": "$.get('new_value', {})", "ap_dispatch": "$.get('dispatch', {})",
|
|
335
|
+
"ap_slack": "$.get('slack_text', '')",
|
|
336
|
+
"ap_error": "$.get('error', $.get('skipped', ''))"}},
|
|
337
|
+
{"name": "mark-resolved", "type": "document",
|
|
338
|
+
"condition": "$.get('ap_resolved', False) == True",
|
|
339
|
+
"config": {"action": "update", "embed-vector": False, "force-update": True},
|
|
340
|
+
"filters": {"document_id": "$.get('ap_doc_id')"},
|
|
341
|
+
"documents": {"approval-request": APPROVAL_UPDATED_VAL}},
|
|
342
|
+
{"name": "confirm", "type": "connector",
|
|
343
|
+
"condition": "$.get('ap_resolved', False) == True",
|
|
344
|
+
"connector": {"command": "slack_notify", "name": "machina-nodes"},
|
|
345
|
+
"inputs": {"text": "$.get('ap_slack', '')"},
|
|
346
|
+
"outputs": {"ap_notified": "$"}},
|
|
347
|
+
]}
|
|
348
|
+
return [("connector", connector), ("workflow", demo),
|
|
349
|
+
("workflow", approval_demo), ("workflow", approval_resolve)]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main():
|
|
353
|
+
if not BASE or not TOKEN:
|
|
354
|
+
sys.exit("Set CLIENT_API_URL and API_TOKEN environment variables.")
|
|
355
|
+
defs = definitions()
|
|
356
|
+
if "--teardown" in sys.argv:
|
|
357
|
+
print(f"Tearing down machina-nodes on {BASE} ...")
|
|
358
|
+
for kind, body in reversed(defs):
|
|
359
|
+
_delete_by_name(kind, body["name"])
|
|
360
|
+
print(f" removed {kind}/{body['name']}")
|
|
361
|
+
return
|
|
362
|
+
print(f"Provisioning machina-nodes on {BASE} ...")
|
|
363
|
+
ok = all(_create(kind, body) for kind, body in defs)
|
|
364
|
+
print("Done." if ok else "Done with errors — check output above.")
|
|
365
|
+
if "--run" in sys.argv:
|
|
366
|
+
print("\nSmoke: posting one marked message via the demo workflow ...")
|
|
367
|
+
r = _req("POST", "workflow/execute/machina-nodes-demo",
|
|
368
|
+
{"text": ":test_tube: machina-nodes — shared node library is live in this pod "
|
|
369
|
+
"(slack_notify + github_issue registered)."})
|
|
370
|
+
print(" execute status:", r.get("status"), "| error:", r.get("error"))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
main()
|
|
@@ -78,6 +78,7 @@ export default defineConfig({
|
|
|
78
78
|
text: "Operations",
|
|
79
79
|
items: [
|
|
80
80
|
{ text: "execution", link: "/commands/execution" },
|
|
81
|
+
{ text: "approvals", link: "/commands/approvals" },
|
|
81
82
|
{ text: "skills", link: "/commands/skills" },
|
|
82
83
|
{ text: "factory", link: "/commands/factory" },
|
|
83
84
|
{ text: "loop", link: "/commands/loop" },
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# approvals
|
|
2
|
+
|
|
3
|
+
The `approvals` command group is the **human side of workflow checkpoints** — the "keep one door open" principle as a first-class primitive. A workflow that produces something risky or publishable (an AI-written article, a bulk data change) can **gate the final action behind a human**: it files an approval request and stops. You list what's waiting, read it, and approve or reject. On approve, the stored action runs **in-pod**.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
machina approvals list [--project <id>] [--all] [--json]
|
|
9
|
+
machina approvals approve <request-id> [--project <id>] [--json]
|
|
10
|
+
machina approvals reject <request-id> [--project <id>] [--json]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Flag | Purpose |
|
|
14
|
+
|------|---------|
|
|
15
|
+
| `--project`, `-p` | A specific project (defaults to the selected project). |
|
|
16
|
+
| `--all`, `-a` | (`list`) Include already-resolved requests. |
|
|
17
|
+
| `--json`, `-j` | Machine-readable output. |
|
|
18
|
+
|
|
19
|
+
## The flow
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
producer workflow ──> approval-request doc + Slack ask ──> human ──> approve/reject
|
|
23
|
+
│
|
|
24
|
+
approve: stored action workflow runs in-pod
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
1. A producer workflow composes a request with the `compose_approval` node (title, preview, and **what should run if approved** — a workflow name + inputs), saves it as an `approval-request` document, and posts the ask to the pod's Slack channel.
|
|
28
|
+
2. A human sees it (Slack or `approvals list`) and decides.
|
|
29
|
+
3. `approve`/`reject` execute the pod's `machina-approval-resolve` workflow — approve dispatches the stored action; reject just records the decision. Double-resolution is guarded: a request resolves exactly once.
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
$ machina approvals list
|
|
33
|
+
|
|
34
|
+
Approval requests — pending
|
|
35
|
+
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
|
36
|
+
┃ Request ┃ Title ┃ Status ┃ On approve, runs ┃
|
|
37
|
+
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
|
38
|
+
│ a1b2c3d4e5 │ Publish: Flamengo x Vasco│ pending │ publish-article │
|
|
39
|
+
└────────────┴──────────────────────────┴─────────┴──────────────────┘
|
|
40
|
+
|
|
41
|
+
$ machina approvals approve a1b2c3d4e5
|
|
42
|
+
Request a1b2c3d4e5 approved.
|
|
43
|
+
action dispatched: publish-article
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
::: tip Resolution logic lives in the pod, not the CLI
|
|
47
|
+
`approve`/`reject` just execute the `machina-approval-resolve` workflow with `{request_id, decision}`. Any surface — this CLI, the Studio, an MCP agent — resolves through the same workflow, so the guard rails (double-resolution, dispatch recording) are shared.
|
|
48
|
+
:::
|
|
49
|
+
|
|
50
|
+
## Gating your own workflow
|
|
51
|
+
|
|
52
|
+
The request side is three tasks from the [`machina-nodes`](https://github.com/machina-sports/machina-cli/blob/main/docs/harness-loop-kit/nodes.py) library (see the provisioned `machina-approval-demo` workflow for a copyable example):
|
|
53
|
+
|
|
54
|
+
1. `compose_approval` — inputs: `title`, `preview`, `action_workflow`, `action_inputs`.
|
|
55
|
+
2. A `document` save task for the composed `approval-request`.
|
|
56
|
+
3. `slack_notify` with the composed ask.
|
|
57
|
+
|
|
58
|
+
An **actionless** request (empty `action_workflow`) is also valid — a pure human acknowledgment gate.
|
|
59
|
+
|
|
60
|
+
## Related
|
|
61
|
+
|
|
62
|
+
- [`loop`](/commands/loop) — the harness loop's `needs_review` is the same principle at the conversation level; `approvals` brings it to any workflow.
|
|
63
|
+
- Provisioning: `docs/harness-loop-kit/nodes.py` in the [machina-cli repo](https://github.com/machina-sports/machina-cli) (`python3 nodes.py` against the pod).
|
|
@@ -67,6 +67,6 @@ machina workflow list
|
|
|
67
67
|
| --- | --- |
|
|
68
68
|
| **Platform** | `login` · `org` · `project` · `credentials` · `connect` · `mcp` |
|
|
69
69
|
| **Resources** | `workflow` · `agent` · `connector` · `mapping` · `prompt` · `document` |
|
|
70
|
-
| **Operations** | `execution` · `skills` · `factory` · `loop` · `context-graph` · `sports` · `template` · `deploy` · `config` · `update` |
|
|
70
|
+
| **Operations** | `execution` · `approvals` · `skills` · `factory` · `loop` · `context-graph` · `sports` · `template` · `deploy` · `config` · `update` |
|
|
71
71
|
|
|
72
72
|
New here? Start with [Installation](/guide/installation), then the [Quickstart](/guide/quickstart).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Human approvals — the workflow checkpoint door.
|
|
2
|
+
|
|
3
|
+
Workflows gate risky/publishable actions behind an `approval-request` document
|
|
4
|
+
(composed by the machina-nodes `compose_approval` node). This command group is
|
|
5
|
+
the human side of that door: list what's waiting, approve or reject. Resolution
|
|
6
|
+
runs IN-POD via the `machina-approval-resolve` workflow, so every surface (CLI,
|
|
7
|
+
Studio, MCP) shares the same logic — the CLI is deliberately thin.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import json as json_lib
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from machina_cli.project_client import ProjectClient
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Human approvals (list / approve / reject workflow checkpoints)")
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _requests(client: ProjectClient, show_all: bool) -> list:
|
|
25
|
+
filters = {"name": "approval-request"}
|
|
26
|
+
if not show_all:
|
|
27
|
+
filters["value.status"] = "pending"
|
|
28
|
+
r = client.post(
|
|
29
|
+
"document/search",
|
|
30
|
+
{"compact": False, "filters": filters, "page": 1, "page_size": 50, "sorters": ["created", -1]},
|
|
31
|
+
)
|
|
32
|
+
d = r.get("data")
|
|
33
|
+
return (d.get("data") if isinstance(d, dict) else d) or []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("list")
|
|
37
|
+
def list_approvals(
|
|
38
|
+
project_id: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID (default: selected project)"),
|
|
39
|
+
show_all: bool = typer.Option(False, "--all", "-a", help="Include already-resolved requests"),
|
|
40
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
41
|
+
):
|
|
42
|
+
"""List approval requests waiting for a human (default: pending only)."""
|
|
43
|
+
client = ProjectClient(project_id)
|
|
44
|
+
rows = _requests(client, show_all)
|
|
45
|
+
|
|
46
|
+
if json_output:
|
|
47
|
+
payload = [{"request_id": (r.get("value") or {}).get("request_id"),
|
|
48
|
+
"title": (r.get("value") or {}).get("title"),
|
|
49
|
+
"status": (r.get("value") or {}).get("status"),
|
|
50
|
+
"action_workflow": ((r.get("value") or {}).get("action") or {}).get("workflow"),
|
|
51
|
+
"requested_at": (r.get("value") or {}).get("requested_at"),
|
|
52
|
+
"created": str(r.get("created") or "")} for r in rows]
|
|
53
|
+
console.print_json(json_lib.dumps(payload, default=str))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if not rows:
|
|
57
|
+
console.print("[green]No pending approvals.[/green]" if not show_all else "[yellow]No approval requests found.[/yellow]")
|
|
58
|
+
return
|
|
59
|
+
table = Table(title="Approval requests" + ("" if show_all else " — pending"))
|
|
60
|
+
table.add_column("Request", style="bold")
|
|
61
|
+
table.add_column("Title", overflow="fold")
|
|
62
|
+
table.add_column("Status")
|
|
63
|
+
table.add_column("On approve, runs", style="dim")
|
|
64
|
+
table.add_column("Requested", style="dim", no_wrap=True)
|
|
65
|
+
for r in rows:
|
|
66
|
+
v = r.get("value") or {}
|
|
67
|
+
status = v.get("status", "?")
|
|
68
|
+
color = {"pending": "yellow", "approved": "green", "rejected": "red"}.get(status, "dim")
|
|
69
|
+
table.add_row(v.get("request_id", "?"), v.get("title", ""),
|
|
70
|
+
f"[{color}]{status}[/]", (v.get("action") or {}).get("workflow") or "—",
|
|
71
|
+
str(v.get("requested_at") or r.get("created") or "")[:16])
|
|
72
|
+
console.print(table)
|
|
73
|
+
console.print(" [dim]Resolve with[/] [bold]machina approvals approve|reject <request-id>[/]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve(request_id: str, decision: str, project_id: Optional[str], json_output: bool) -> None:
|
|
77
|
+
client = ProjectClient(project_id)
|
|
78
|
+
try:
|
|
79
|
+
resolver = getpass.getuser()
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
resolver = "cli"
|
|
82
|
+
result = client.post(
|
|
83
|
+
"workflow/execute/machina-approval-resolve",
|
|
84
|
+
{"request_id": request_id, "decision": decision, "resolver": resolver},
|
|
85
|
+
)
|
|
86
|
+
data = result.get("data") or {}
|
|
87
|
+
if json_output:
|
|
88
|
+
console.print_json(json_lib.dumps(data, default=str))
|
|
89
|
+
return
|
|
90
|
+
resolved = data.get("resolved")
|
|
91
|
+
dispatch = data.get("dispatch") or {}
|
|
92
|
+
error = data.get("error") or ""
|
|
93
|
+
if resolved is True or (resolved is None and result.get("status")):
|
|
94
|
+
verb = "approved" if decision == "approve" else "rejected"
|
|
95
|
+
console.print(f"[green]Request {request_id} {verb}.[/green]")
|
|
96
|
+
if dispatch.get("dispatched"):
|
|
97
|
+
console.print(f" [dim]action dispatched:[/] [bold]{dispatch.get('workflow')}[/]")
|
|
98
|
+
elif decision == "approve" and dispatch.get("workflow"):
|
|
99
|
+
console.print(f" [red]action dispatch failed:[/] {dispatch.get('error')}")
|
|
100
|
+
else:
|
|
101
|
+
console.print(f"[red]Could not resolve {request_id}:[/red] {error or 'unknown error'}")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("approve")
|
|
106
|
+
def approve(
|
|
107
|
+
request_id: str = typer.Argument(..., help="The approval request id (from `approvals list` or Slack)"),
|
|
108
|
+
project_id: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID (default: selected project)"),
|
|
109
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
110
|
+
):
|
|
111
|
+
"""Approve a request — the stored action workflow runs in-pod."""
|
|
112
|
+
_resolve(request_id, "approve", project_id, json_output)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("reject")
|
|
116
|
+
def reject(
|
|
117
|
+
request_id: str = typer.Argument(..., help="The approval request id (from `approvals list` or Slack)"),
|
|
118
|
+
project_id: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID (default: selected project)"),
|
|
119
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
120
|
+
):
|
|
121
|
+
"""Reject a request — recorded, nothing runs."""
|
|
122
|
+
_resolve(request_id, "reject", project_id, json_output)
|
|
@@ -8,6 +8,7 @@ from rich.text import Text
|
|
|
8
8
|
|
|
9
9
|
from machina_cli import __version__
|
|
10
10
|
from machina_cli.commands import (
|
|
11
|
+
approvals,
|
|
11
12
|
auth,
|
|
12
13
|
org,
|
|
13
14
|
project,
|
|
@@ -60,6 +61,7 @@ CMD_GROUPS = [
|
|
|
60
61
|
"Operations",
|
|
61
62
|
[
|
|
62
63
|
("execution", "Executions"),
|
|
64
|
+
("approvals", "Human approvals"),
|
|
63
65
|
("skills", "Skills"),
|
|
64
66
|
("loop", "Agentic turn loop (harness)"),
|
|
65
67
|
("factory", "Build apps (Factory)"),
|
|
@@ -174,6 +176,7 @@ app.add_typer(agent.app, name="agent", help="Agent management")
|
|
|
174
176
|
app.add_typer(template.app, name="template", help="Template management")
|
|
175
177
|
app.add_typer(skills.app, name="skills", help="Skills management")
|
|
176
178
|
app.add_typer(execution.app, name="execution", help="Execution management")
|
|
179
|
+
app.add_typer(approvals.app, name="approvals", help="Human approvals (workflow checkpoints)")
|
|
177
180
|
app.add_typer(loop.app, name="loop", help="Durable agentic turn loop (harness)")
|
|
178
181
|
app.add_typer(
|
|
179
182
|
context_graph.app, name="context-graph", help="Self-healing / monitoring status across projects"
|
|
@@ -31,6 +31,7 @@ REPL_COMMANDS = [
|
|
|
31
31
|
"factory",
|
|
32
32
|
"template",
|
|
33
33
|
"execution",
|
|
34
|
+
"approvals",
|
|
34
35
|
"credentials",
|
|
35
36
|
"deploy",
|
|
36
37
|
"config",
|
|
@@ -94,6 +95,7 @@ SUB_COMMANDS = {
|
|
|
94
95
|
],
|
|
95
96
|
"template": ["list", "install", "push"],
|
|
96
97
|
"execution": ["get", "list"],
|
|
98
|
+
"approvals": ["list", "approve", "reject"],
|
|
97
99
|
"credentials": ["list", "generate", "revoke"],
|
|
98
100
|
"deploy": ["start", "status", "restart"],
|
|
99
101
|
"config": ["list", "set", "get"],
|
|
@@ -203,6 +205,7 @@ def _show_help():
|
|
|
203
205
|
"Operations",
|
|
204
206
|
[
|
|
205
207
|
("execution list|get <id>", "Execution history"),
|
|
208
|
+
("approvals list|approve|reject <id>", "Human approval checkpoints"),
|
|
206
209
|
("skills list|install|info|run|push|constructor", "Skills-first surface"),
|
|
207
210
|
("factory run|status|watch|logs|list", "Build apps (Factory coding-agent)"),
|
|
208
211
|
("sports <module> <command>", "Sports-skills passthrough"),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tests for `machina approvals` — the human side of workflow approval checkpoints.
|
|
2
|
+
|
|
3
|
+
The CLI is deliberately thin: list reads `approval-request` documents; approve/
|
|
4
|
+
reject execute the in-pod `machina-approval-resolve` workflow so every surface
|
|
5
|
+
shares the same resolution logic. These pin that contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
from typer.testing import CliRunner
|
|
14
|
+
|
|
15
|
+
from machina_cli.main import app
|
|
16
|
+
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _doc(request_id, title, status="pending", workflow="publish-article"):
|
|
21
|
+
return {"created": "Thu, 02 Jul 2026 12:00:00 GMT",
|
|
22
|
+
"value": {"request_id": request_id, "title": title, "status": status,
|
|
23
|
+
"action": {"workflow": workflow}, "requested_at": "2026-07-02T12:00:00+00:00"}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_list_renders_pending_requests():
|
|
27
|
+
client = MagicMock()
|
|
28
|
+
client.post.return_value = {"data": {"data": [_doc("abc123", "Publish: Flamengo preview")]}}
|
|
29
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
30
|
+
result = runner.invoke(app, ["approvals", "list"])
|
|
31
|
+
assert result.exit_code == 0
|
|
32
|
+
assert "abc123" in result.output and "Flamengo" in result.output
|
|
33
|
+
body = client.post.call_args[0][1]
|
|
34
|
+
assert body["filters"] == {"name": "approval-request", "value.status": "pending"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_list_all_drops_the_status_filter():
|
|
38
|
+
client = MagicMock()
|
|
39
|
+
client.post.return_value = {"data": {"data": []}}
|
|
40
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
41
|
+
result = runner.invoke(app, ["approvals", "list", "--all"])
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert client.post.call_args[0][1]["filters"] == {"name": "approval-request"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_list_json_is_parseable():
|
|
47
|
+
client = MagicMock()
|
|
48
|
+
client.post.return_value = {"data": {"data": [_doc("abc123", "T")]}}
|
|
49
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
50
|
+
result = runner.invoke(app, ["approvals", "list", "--json"])
|
|
51
|
+
assert result.exit_code == 0
|
|
52
|
+
payload = json.loads(result.output)
|
|
53
|
+
assert payload[0]["request_id"] == "abc123"
|
|
54
|
+
assert payload[0]["action_workflow"] == "publish-article"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_approve_executes_the_resolve_workflow():
|
|
58
|
+
client = MagicMock()
|
|
59
|
+
client.post.return_value = {"status": True, "data": {"resolved": True,
|
|
60
|
+
"dispatch": {"dispatched": True, "workflow": "publish-article"}}}
|
|
61
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
62
|
+
result = runner.invoke(app, ["approvals", "approve", "abc123"])
|
|
63
|
+
assert result.exit_code == 0
|
|
64
|
+
path, body = client.post.call_args[0]
|
|
65
|
+
assert path == "workflow/execute/machina-approval-resolve"
|
|
66
|
+
assert body["request_id"] == "abc123" and body["decision"] == "approve"
|
|
67
|
+
assert "approved" in result.output and "publish-article" in result.output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_reject_sends_reject_decision():
|
|
71
|
+
client = MagicMock()
|
|
72
|
+
client.post.return_value = {"status": True, "data": {"resolved": True, "dispatch": {"dispatched": False}}}
|
|
73
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
74
|
+
result = runner.invoke(app, ["approvals", "reject", "abc123"])
|
|
75
|
+
assert result.exit_code == 0
|
|
76
|
+
assert client.post.call_args[0][1]["decision"] == "reject"
|
|
77
|
+
assert "rejected" in result.output
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_resolve_failure_exits_nonzero_with_reason():
|
|
81
|
+
client = MagicMock()
|
|
82
|
+
client.post.return_value = {"status": True, "data": {"resolved": False, "error": "request zz not found"}}
|
|
83
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
84
|
+
result = runner.invoke(app, ["approvals", "approve", "zz"])
|
|
85
|
+
assert result.exit_code == 1
|
|
86
|
+
assert "not found" in result.output
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_approve_reports_dispatch_failure():
|
|
90
|
+
client = MagicMock()
|
|
91
|
+
client.post.return_value = {"status": True, "data": {"resolved": True,
|
|
92
|
+
"dispatch": {"dispatched": False, "workflow": "w", "error": "boom"}}}
|
|
93
|
+
with patch("machina_cli.commands.approvals.ProjectClient", return_value=client):
|
|
94
|
+
result = runner.invoke(app, ["approvals", "approve", "abc123"])
|
|
95
|
+
assert result.exit_code == 0
|
|
96
|
+
assert "dispatch failed" in result.output and "boom" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|