Skip to content

Commit f77fb98

Browse files
committed
feat: hosted morph service, shared inspection, and org-level policy (Phase 7)
Introduce morph-serve as a multi-repo hosted service with a stable REST API for browsing behavioral history, commits, runs, traces, pipelines, and certifications. Add organization-level policy with presets and threshold merging. Wire `morph serve` CLI command for launching the service. Update the inspector UI with multi-repo selection and behavioral status display. 303 unit/integration tests passed, 29 E2E scenarios (26 passed, 3 skipped). Made-with: Cursor
1 parent e13366b commit f77fb98

18 files changed

Lines changed: 2896 additions & 346 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ morph setup cursor # writes .cursor/ (MCP, hooks, rules)
2424

2525
Then open the project in Cursor. Ensure `morph` and `morph-mcp` are on your PATH. The MCP server and hooks will record prompts/responses and let the agent commit via Morph.
2626

27+
## Hosted service (team inspection)
28+
29+
Run the Morph hosted service for shared, browser-based inspection of behavioral history:
30+
31+
```bash
32+
morph serve # serve current repo at http://127.0.0.1:8765
33+
morph serve --repo team=/path/to/repo # named multi-repo mode
34+
morph serve --org-policy org-policy.json # apply org-level policy
35+
```
36+
37+
The service exposes a stable JSON API and browser UI for inspecting commits (with certification/gate status), runs, traces, pipelines, merge dominance, and policy. See [v0-spec.md § 15](docs/v0-spec.md#15-hosted-service-phase-7).
38+
2739
## Develop Morph (this repo)
2840

2941
```bash

docs/INSTALLATION.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,26 @@ Morph works with **Cursor** and **Claude Code**. Each IDE uses the same `morph-m
8282

8383
---
8484

85-
## 5. Next steps
85+
## 5. Run the hosted service (optional)
86+
87+
For team-wide inspection and collaboration, run the Morph hosted service:
88+
89+
```bash
90+
morph serve # serve current repo at http://127.0.0.1:8765
91+
morph serve --port 9000 # custom port
92+
morph serve --repo team=/path # named multi-repo
93+
```
94+
95+
The service exposes a stable JSON API for browsing commits (with behavioral status), runs, traces, pipelines, certifications, and policy. See [v0-spec.md § 15](v0-spec.md#15-hosted-service-phase-7) for the full API reference.
96+
97+
---
98+
99+
## 6. Next steps
86100

87101
- **Commit the filesystem:** [CURSOR-SETUP.md § Committing](CURSOR-SETUP.md#5-committing-the-filesystem) / [CLAUDE-CODE-SETUP.md § Committing](CLAUDE-CODE-SETUP.md#4-committing-the-filesystem).
88102
- **Use Morph with Git:** [MORPH-AND-GIT.md](MORPH-AND-GIT.md).
89103
- **MCP tool reference:** [CURSOR-SETUP.md § MCP Tool Reference](CURSOR-SETUP.md#6-mcp-tool-reference) (same tools in Claude Code).
104+
- **Hosted service API:** [v0-spec.md § 15](v0-spec.md#15-hosted-service-phase-7).
90105

91106
---
92107

docs/MORPH-AND-GIT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,4 @@ morph gate --json
156156
- **Branches**: Each system has its own. You can align names (`main` in both) for clarity, but Morph never reads Git refs.
157157
- **Remotes**: Morph has its own remote model. Use `morph remote add` to configure Morph remotes, and Git remotes for source. They are independent.
158158
- **CI**: Clone the Git repo (including `.morph/` if committed), then run Morph CLI against the same tree. Use `morph certify` and `morph gate` for behavioral gating.
159+
- **Hosted service**: Run `morph serve` to expose a shared HTTP API for browsing Morph state (commits, behavioral status, runs, pipelines). The service reads from `.morph/` and is complementary to Git hosting (GitHub, GitLab). Think of it as a behavioral evidence dashboard alongside your code review tool.

docs/TESTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
| **morph-cli** | 76 integration tests | YAML specs in `morph-cli/tests/specs/*.yaml`, compiled by `build.rs` |
99
| **morph-e2e** | 25 Cucumber e2e scenarios | `morph-e2e/features/*.feature`, step defs in `morph-e2e/tests/cucumber.rs` |
1010
| **morph-mcp** | None yet | -- |
11-
| **morph-serve** | None yet | -- |
11+
| **morph-serve** | 34+ unit/API tests (views, service, handlers, org policy, multi-repo) | `morph-serve/src/tests.rs` + `org_policy::tests` |
1212

1313
### morph-core unit test modules
1414

@@ -71,7 +71,7 @@ Each YAML spec supports: file/directory setup (`files`, `dirs`), sequenced CLI c
7171
## Known gaps
7272

7373
- **morph-mcp**: No tests. An integration harness that speaks MCP over stdio would cover the primary write path.
74-
- **morph-serve**: No tests. Could test API endpoints with axum's test utilities.
74+
- **morph-serve**: 34+ tests covering repo list/summary, branch listing, commit list/detail, run/trace/pipeline detail, annotations, policy, gate status, behavioral status (certification/merge), org policy CRUD, multi-repo, backward-compatible endpoints, and error paths.
7575
- **GixStore-specific paths**: `status()` and `record_session()` are now backend-aware (use `store.hash_object()`), but explicit GixStore integration tests would catch backend-specific regressions.
7676
- **proptest**: In dev-dependencies but not yet used. Good candidate for property-based tests on hash determinism and serialization round-trips.
7777
- **Error paths**: Many functions have untested error branches (malformed JSON, permission errors, missing refs).

docs/v0-spec.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1002,4 +1002,57 @@ The reference v0 implementation is in Rust.
10021002
| `morph-core` | Library: object model, storage, hashing, commits, metrics, trees, migration |
10031003
| `morph-cli` | CLI: read path + manual writes (`morph init`, `add`, `commit`, `log`, ...) |
10041004
| `morph-mcp` | Cursor MCP server: primary write path from the IDE |
1005-
| `morph-serve` | Browser visualization: `morph visualize` serves a web UI for browsing commits |
1005+
| `morph-serve` | Hosted service: shared inspection and policy layer (`morph serve` and `morph visualize`) with stable JSON API, multi-repo support, behavioral status derivation, org-level policy |
1006+
1007+
---
1008+
1009+
# 15. Hosted Service (Phase 7)
1010+
1011+
The hosted service (`morph serve`) exposes the Morph object graph through a stable HTTP/JSON API for collaborative team inspection.
1012+
1013+
## Starting the service
1014+
1015+
```bash
1016+
morph serve # serve current repo on :8765
1017+
morph serve --port 9000 # custom port
1018+
morph serve --repo alpha=/path/to/repo # multi-repo mode
1019+
morph serve --org-policy org-policy.json # apply org-level policy
1020+
```
1021+
1022+
## API surface
1023+
1024+
All repo-scoped endpoints live under `/api/repos/{name}/...`.
1025+
1026+
| Endpoint | Method | Returns |
1027+
|---|---|---|
1028+
| `/api/repos` | GET | List of configured repos with summary stats |
1029+
| `/api/repos/{repo}/summary` | GET | Repo summary: head, branches, commit/run counts |
1030+
| `/api/repos/{repo}/branches` | GET | Branch listing with current branch |
1031+
| `/api/repos/{repo}/commits` | GET | Commit history from HEAD with behavioral badges |
1032+
| `/api/repos/{repo}/commits/{hash}` | GET | Full commit detail with behavioral status |
1033+
| `/api/repos/{repo}/runs` | GET | Run listing |
1034+
| `/api/repos/{repo}/runs/{hash}` | GET | Run detail with agent, environment, metrics |
1035+
| `/api/repos/{repo}/traces/{hash}` | GET | Trace events (prompt/response text) |
1036+
| `/api/repos/{repo}/pipelines/{hash}` | GET | Pipeline graph, provenance, attribution |
1037+
| `/api/repos/{repo}/objects/{hash}` | GET | Raw object JSON |
1038+
| `/api/repos/{repo}/annotations/{hash}` | GET | Annotations on a target |
1039+
| `/api/repos/{repo}/policy` | GET | Effective policy (repo + org merged) |
1040+
| `/api/repos/{repo}/gate/{hash}` | GET | Gate check result for a commit |
1041+
| `/api/org/policy` | GET/POST | Organization-level policy |
1042+
1043+
Backward-compatible endpoints (`/api/log`, `/api/runs`, `/api/object/{hash}`, `/api/graph`) route to the default repo.
1044+
1045+
## Behavioral status
1046+
1047+
The commit detail endpoint returns a `behavioral_status` object:
1048+
1049+
- `certified`: whether the commit has a passing certification annotation
1050+
- `certification`: details (runner, eval_suite, metrics, failures)
1051+
- `gate_passed`: whether the commit satisfies the repo policy
1052+
- `gate_reasons`: list of reasons for gate failure
1053+
- `is_merge`: true if the commit has 2+ parents
1054+
- `merge_status`: parent metrics and dominance results for merge commits
1055+
1056+
## Organization-level policy
1057+
1058+
An optional org-level policy file can set default required_metrics, thresholds, and named presets. The effective policy for each repo is the union of org and repo policies (repo overrides win for thresholds).

morph-cli/src/main.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,20 @@ enum Command {
227227
#[arg(long, default_value = "127.0.0.1")]
228228
interface: String,
229229
},
230+
/// Run the Morph hosted service (multi-repo inspection, shared API, org policy).
231+
#[cfg(feature = "visualize")]
232+
Serve {
233+
/// Repository paths as name=path pairs. Defaults to current repo as "default".
234+
#[arg(long = "repo", value_name = "NAME=PATH")]
235+
repos: Vec<String>,
236+
#[arg(long, default_value = "8765")]
237+
port: u16,
238+
#[arg(long, default_value = "127.0.0.1")]
239+
interface: String,
240+
/// Path to an org-level policy JSON file
241+
#[arg(long)]
242+
org_policy: Option<PathBuf>,
243+
},
230244
}
231245

232246
#[derive(clap::Subcommand)]
@@ -476,6 +490,43 @@ fn main() -> anyhow::Result<()> {
476490
.map_err(|_| anyhow::anyhow!("invalid interface or port: {}:{}", interface, port))?;
477491
morph_serve::run_blocking(morph_dir, addr).map_err(|e| anyhow::anyhow!("{}", e))?;
478492
}
493+
#[cfg(feature = "visualize")]
494+
Command::Serve { repos, port, interface, org_policy } => {
495+
let repo_entries = if repos.is_empty() {
496+
let cwd = std::env::current_dir()?;
497+
let repo_root = find_repo(&cwd)
498+
.ok_or_else(|| anyhow::anyhow!("not a morph repository (or any parent); specify --repo name=path"))?;
499+
vec![morph_serve::RepoEntry {
500+
name: "default".to_string(),
501+
morph_dir: repo_root.join(".morph"),
502+
}]
503+
} else {
504+
let mut entries = Vec::new();
505+
for spec in &repos {
506+
let (name, path_str) = spec.split_once('=')
507+
.ok_or_else(|| anyhow::anyhow!("repo spec must be name=path, got: {}", spec))?;
508+
let path = PathBuf::from(path_str);
509+
let morph_dir = if path.join(".morph").exists() {
510+
path.join(".morph")
511+
} else {
512+
find_repo(&path)
513+
.ok_or_else(|| anyhow::anyhow!("not a morph repository: {}", path_str))?
514+
.join(".morph")
515+
};
516+
entries.push(morph_serve::RepoEntry { name: name.to_string(), morph_dir });
517+
}
518+
entries
519+
};
520+
let addr: std::net::SocketAddr = format!("{}:{}", interface, port)
521+
.parse()
522+
.map_err(|_| anyhow::anyhow!("invalid interface or port: {}:{}", interface, port))?;
523+
let config = morph_serve::ServiceConfig {
524+
repos: repo_entries,
525+
addr,
526+
org_policy_path: org_policy,
527+
};
528+
morph_serve::run_service(config).map_err(|e| anyhow::anyhow!("{}", e))?;
529+
}
479530
Command::Prompt { sub } => match sub {
480531
PromptCmd::Create { path } => {
481532
let (repo_root, store) = get_store(verbose)?;

morph-cli/tests/specs/serve.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
- name: serve_help
2+
steps:
3+
- morph: [serve, --help]
4+
stdout_contains: "Run the Morph hosted service"
5+
6+
- name: serve_requires_repo_or_flag
7+
init: false
8+
steps:
9+
- morph: [serve, --port, "0"]
10+
expect_exit: 1
11+
stderr_contains: "not a morph repository"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
Feature: Hosted inspection workflow
2+
Teams use the Morph hosted service to inspect commits,
3+
runs, traces, pipelines, and behavioral status through
4+
a stable HTTP/API surface.
5+
6+
Scenario: Team inspects a certified commit through the hosted service
7+
Given a morph repo
8+
And a file "src/main.py" with content "print('hello')"
9+
When I run "morph add ."
10+
Then the last command succeeded
11+
When I run "morph commit -m initial --metrics {\"acc\":0.95,\"f1\":0.88}"
12+
Then the last command succeeded
13+
When I capture the last output as "commit_hash"
14+
When I create a JSON file "metrics.json" with metrics "acc=0.95,f1=0.88"
15+
When I run "morph certify --metrics-file metrics.json --runner ci-bot"
16+
Then the last command succeeded
17+
When I start the morph server on port "19871"
18+
And I query the server at "/api/repos/default/commits/<commit_hash>"
19+
Then the JSON response field "behavioral_status.certified" equals "true"
20+
And the JSON response field "behavioral_status.certification.runner" equals "ci-bot"
21+
And the JSON response field "eval_contract.observed_metrics.acc" equals "0.95"
22+
When I query the server at "/api/repos/default/summary"
23+
Then the JSON response field "commit_count" equals "1"
24+
And I stop the morph server
25+
26+
Scenario: Team inspects merge status through the hosted service
27+
Given a morph repo
28+
And the identity pipeline and a minimal eval suite exist
29+
And a file "a.txt" with content "aaa"
30+
When I run "morph add ."
31+
Then the last command succeeded
32+
When I run "morph pipeline create prog.json"
33+
And I capture the last output as "prog_hash"
34+
When I run "morph commit -m main-commit --pipeline <prog_hash> --metrics {\"acc\":0.8}"
35+
Then the last command succeeded
36+
When I run "morph branch feature"
37+
And I run "morph checkout feature"
38+
Then the last command succeeded
39+
When I run "morph commit -m feature-commit --pipeline <prog_hash> --metrics {\"acc\":0.85}"
40+
Then the last command succeeded
41+
When I run "morph checkout main"
42+
Then the last command succeeded
43+
When I run "morph merge feature -m merged --pipeline <prog_hash> --metrics {\"acc\":0.9}"
44+
Then the last command succeeded
45+
When I capture the last output as "merge_hash"
46+
When I start the morph server on port "19872"
47+
And I query the server at "/api/repos/default/commits/<merge_hash>"
48+
Then the JSON response field "behavioral_status.is_merge" equals "true"
49+
And the JSON response field "behavioral_status.merge_status.dominates_a" is present
50+
And the JSON response field "behavioral_status.merge_status.dominates_b" is present
51+
And I stop the morph server
52+
53+
Scenario: Team inspects extracted pipeline and source run
54+
Given a morph repo
55+
And a file "code.py" with content "print('hi')"
56+
When I run "morph add ."
57+
Then the last command succeeded
58+
When I run record-session with prompt "Build feature X" and response "Feature X built"
59+
Then the last command succeeded
60+
When I capture the last output as "run_hash"
61+
When I run "morph pipeline extract --from-run <run_hash>"
62+
Then the last command succeeded
63+
When I capture the last output as "pipeline_hash"
64+
When I run "morph commit -m snap"
65+
Then the last command succeeded
66+
When I start the morph server on port "19873"
67+
And I query the server at "/api/repos/default/runs/<run_hash>"
68+
Then the JSON response field "agent.id" is present
69+
And the JSON response field "trace" is present
70+
When I query the server at "/api/repos/default/pipelines/<pipeline_hash>"
71+
Then the JSON response field "provenance.method" equals "extracted"
72+
And the JSON response field "provenance.derived_from_run" equals "<run_hash>"
73+
And the JSON response field "node_count" equals "2"
74+
And I stop the morph server
75+
76+
Scenario: Invalid repo or missing object returns clear service error
77+
Given a morph repo
78+
When I start the morph server on port "19874"
79+
And I query the server at "/api/repos/default/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
80+
Then the JSON response code is "404"
81+
And the JSON response field "code" equals "not_found"
82+
When I query the server at "/api/repos/nonexistent/summary"
83+
Then the JSON response code is "404"
84+
And the JSON response field "code" equals "repo_not_found"
85+
When I query the server at "/api/repos/default/commits/not-a-hash"
86+
Then the JSON response code is "400"
87+
And the JSON response field "code" equals "bad_hash"
88+
And I stop the morph server

0 commit comments

Comments
 (0)